Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,13 +588,27 @@ def get_taxa(self, obj):
return [{"id": taxon.id, "name": taxon.name} for taxon in obj.taxa.all()]


def agreement_requested(request: Request | None) -> bool:
"""Whether ``with_agreement=true`` is set, gating the heavier agreed_exact_count."""
if request is None:
return False
value = request.query_params.get("with_agreement", "")
return str(value).lower() in ("true", "1", "yes", "on")


class TaxonListSerializer(DefaultSerializer):
# latest_detection = DetectionNestedSerializer(read_only=True)
occurrences = serializers.SerializerMethodField()
parents = TaxonParentSerializer(many=True, read_only=True, source="parents_json")
parent_id = serializers.PrimaryKeyRelatedField(queryset=Taxon.objects.all(), source="parent")
tags = serializers.SerializerMethodField()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# agreed_exact_count is a gated annotation: omit it unless with_agreement=true.
if not agreement_requested(self.context.get("request")):
self.fields.pop("agreed_exact_count", None)

def get_tags(self, obj):
tag_list = getattr(obj, "prefetched_tags", [])
return TagSerializer(tag_list, many=True, context=self.context).data
Expand All @@ -609,6 +623,9 @@ class Meta:
"parents",
"details",
"occurrences_count",
"verified_count",
"agreed_with_prediction_count",
"agreed_exact_count",
"occurrences",
"tags",
"last_detected",
Expand Down Expand Up @@ -886,6 +903,9 @@ class Meta:
"parents",
"details",
"occurrences_count",
"verified_count",
"agreed_with_prediction_count",
"agreed_exact_count",
"events_count",
"occurrences",
"gbif_taxon_key",
Expand Down
111 changes: 111 additions & 0 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
from ami.utils.storages import ConnectionTestResult

from ..models import (
BEST_IDENTIFICATION_ORDER,
BEST_MACHINE_PREDICTION_ORDER,
NULL_DETECTIONS_FILTER,
Classification,
Deployment,
Expand Down Expand Up @@ -1428,6 +1430,7 @@ class TaxonViewSet(DefaultViewSet, ProjectMixin):
"created_at",
"updated_at",
"occurrences_count",
"verified_count",
"last_detected",
"best_determination_score",
"name",
Expand Down Expand Up @@ -1654,6 +1657,114 @@ def get_taxa_observed(
# Efficient EXISTS check that uses the composite index
qs = qs.filter(models.Exists(Occurrence.objects.filter(base_filter)))

qs = self.add_verification_data(qs, occurrence_filters, default_filters_q)

return qs

def _include_agreement(self) -> bool:
"""Whether the heavier ``agreed_exact_count`` annotation should be computed."""
if self.action == "retrieve":
return True
return bool(BooleanField(required=False).clean(self.request.query_params.get("with_agreement")))

def add_verification_data(
self, qs: QuerySet, occurrence_filters: models.Q, default_filters_q: models.Q
) -> QuerySet:
"""
Annotate per-taxon verification and human/model agreement counts, and apply the
``verified=true|false`` filter on list responses.

Counts roll up descendant occurrences (verifying a species also counts toward its
genus/family rows) and respect the project's default filters (same
``apply_defaults`` handling as ``occurrences_count``).

All three counts only concern *verified* occurrences (those with a non-withdrawn
Identification), which are sparse relative to all occurrences. So the hierarchical
rollup is computed in a single pass over that small set in Python and applied as
constant-time ``CASE`` annotations. A correlated ``parents_json`` subquery per
taxon does not scale: on large projects it forces a per-row scan that the GIN
index can't serve (the containment RHS is an ``OuterRef``), timing out the list.
"""
include_agreement = self._include_agreement()

# The chosen (best, non-withdrawn) identification's agreed_with_prediction FK.
best_identification_agreed_prediction = models.Subquery(
Identification.objects.filter(occurrence=models.OuterRef("pk"), withdrawn=False)
.order_by(*BEST_IDENTIFICATION_ORDER)
.values("agreed_with_prediction_id")[:1]
)
verified_occurrences = (
Occurrence.objects.filter(occurrence_filters)
.filter(default_filters_q)
.filter(models.Exists(Identification.objects.filter(occurrence=models.OuterRef("pk"), withdrawn=False)))
.annotate(_agreed_prediction_id=best_identification_agreed_prediction)
)
Comment on lines +1696 to +1701
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file explicitly (should exist)
ls -la ami/main/api/views.py

# Show the code around the reported snippet and the rollup loop
python3 - <<'PY'
import itertools,sys,os
path="ami/main/api/views.py"
start=1660
end=1765
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i in range(start-1,end):
    if i<0 or i>=len(lines): 
        continue
    print(f"{i+1:5d} {lines[i].rstrip()}")
PY

# Find where occurrence_filters is built (and whether it includes collection joins)
rg -n "def get_occurrence_filters|occurrence_filters\s*=" ami/main/api/views.py

# Print the get_occurrence_filters implementation if in this file
rg -n "get_occurrence_filters" ami/main/api/views.py

# Also search for the TaxonViewSet mention and how it filters by collections/detections__source_image__collections
rg -n "TaxonViewSet|get_occurrence_filters|detections__source_image__collections" ami/main/api/views.py

Repository: RolnickLab/antenna

Length of output: 7497


🏁 Script executed:

#!/bin/bash
set -euo pipefail

path="ami/main/api/views.py"
# print get_occurrence_filters implementation region around 1480-1565
python3 - <<'PY'
path="ami/main/api/views.py"
start=1460
end=1585
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i in range(start-1,end):
    if 0<=i<len(lines):
        print(f"{i+1:5d} {lines[i].rstrip()}")
PY

# show add_verification_data call site around 1590-1665 to see parameters/conditions
python3 - <<'PY'
path="ami/main/api/views.py"
start=1560
end=1675
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i in range(start-1,end):
    if 0<=i<len(lines):
        print(f"{i+1:5d} {lines[i].rstrip()}")
PY

# extract BEST_MACHINE_PREDICTION_ORDER and value field meaning maybe
rg -n "BEST_MACHINE_PREDICTION_ORDER" ami/main/api/views.py

Repository: RolnickLab/antenna

Length of output: 12014


🏁 Script executed:

#!/bin/bash
set -euo pipefail

path="ami/main/api/views.py"
# print get_occurrence_filters implementation region around 1480-1565
python3 - <<'PY'
path="ami/main/api/views.py"
start=1460
end=1585
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i in range(start-1,end):
    if 0<=i<len(lines):
        print(f"{i+1:5d} {lines[i].rstrip()}")
PY

# show add_verification_data call site around 1590-1665 to see parameters/conditions
python3 - <<'PY'
path="ami/main/api/views.py"
start=1560
end=1675
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i in range(start-1,end):
    if 0<=i<len(lines):
        print(f"{i+1:5d} {lines[i].rstrip()}")
PY

# extract BEST_MACHINE_PREDICTION_ORDER and value field meaning maybe
rg -n "BEST_MACHINE_PREDICTION_ORDER" ami/main/api/views.py

Repository: RolnickLab/antenna

Length of output: 12014


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search within views.py for determination__parents_json usage in add_verification_data context
rg -n "add_verification_data|determination__parents_json|determination_id|verified_occurrences" ami/main/api/views.py

# Search other files for occurrence verification rollup maybe
rg -n "verified_occurrences|agreed_with_prediction_counts|agreed_exact_counts" -S ami/main/api/views.py ami/main/api -g'*.py'

# Search where determination fields are annotated for Occurrence in this viewset
rg -n "determination\s*=" ami/main/api/views.py

# Show Occurrence model relations if present in repository to understand if occurrences join to determination, etc.
fd "models.py" ami/main -a | head -n 20
rg -n "class Occurrence|determinatio[n_]" ami/main -g'*.py' | head -n 50

Repository: RolnickLab/antenna

Length of output: 8562


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search within views.py for determination__parents_json usage in add_verification_data context
rg -n "add_verification_data|determination__parents_json|determination_id|verified_occurrences" ami/main/api/views.py

# Search other files for occurrence verification rollup maybe
rg -n "verified_occurrences|agreed_with_prediction_counts|agreed_exact_counts" -S ami/main/api/views.py ami/main/api -g'*.py'

# Search where determination fields are annotated for Occurrence in this viewset
rg -n "determination\s*=" ami/main/api/views.py

# Locate Occurrence model definition and its determination relation
fd -t f "models.py" ami/main -x sh -c 'echo {}' | head -n 200
rg -n "class Occurrence|determination__parents_json|parents_json" ami/main -g'*.py' | head -n 120

Repository: RolnickLab/antenna

Length of output: 10895


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re
path="ami/main/models.py"
# find line numbers for relevant classes/fields
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
def find(pattern):
    for i,l in enumerate(lines,1):
        if re.search(pattern,l):
            return i
    return None

targets=[
    r"class Occurrence\b",
    r"class Detection\b",
    r"class Identification\b",
    r"\bdetections\b",
    r"occurrences\b",
]
for t in targets:
    ln=find(t)
    print(t,ln)
PY

# Print the Occurrence model section (first ~200 lines after class definition)
python3 - <<'PY'
path="ami/main/models.py"
start=3140  # approx from earlier rg
end=3320
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i in range(start-1,end):
    print(f"{i+1:5d} {lines[i].rstrip()}")
PY

# Print Detection and relation to occurrences (search for class Detection)
python3 - <<'PY'
import re
path="ami/main/models.py"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
start=None
for i,l in enumerate(lines,1):
    if re.match(r"class Detection\b", l):
        start=i
        break
print("Detection starts at", start)
end=start+220
for i in range(start-1, end-1):
    print(f"{i+1:5d} {lines[i].rstrip()}")
PY

# Print Identification relation to occurrence if helpful
python3 - <<'PY'
import re
path="ami/main/models.py"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
start=None
for i,l in enumerate(lines,1):
    if re.match(r"class Identification\b", l):
        start=i
        break
print("Identification starts at", start)
end=start+120
for i in range(start-1, end-1):
    print(f"{i+1:5d} {lines[i].rstrip()}")
PY

Repository: RolnickLab/antenna

Length of output: 22136


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for verified_count usage in code/tests
rg -n "verified_count|agreed_with_prediction_count|agreed_exact_count|with_agreement" ami/main/tests.py ami/main/api -g'*.py'

# Search for query param "collection=" in tests around taxa verified counts
rg -n "collection=" ami/main/tests.py -g'*.py'

# Search for "OccurencesVerified" filter or endpoints
rg -n "OccurenceVerified|OccurrenceVerified" ami/main/api/views.py ami/main/api -g'*.py'

# If possible, locate serializer/response field mapping for these counts
rg -n "agreed_exact_count|agreed_with_prediction_count" ami/main/api/serializers.py -g'*.py'

Repository: RolnickLab/antenna

Length of output: 5077


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for verified_count usage in code/tests
rg -n "verified_count|agreed_with_prediction_count|agreed_exact_count|with_agreement" ami/main/tests.py ami/main/api -g'*.py'

# Search for query param "collection=" in tests around taxa verified counts
rg -n "collection=" ami/main/tests.py -g'*.py'

# Search for "OccurencesVerified" filter or endpoints
rg -n "OccurrenceVerified" ami/main/api/views.py ami/main/api -g'*.py'

# Locate serializer response fields for these counts
rg -n "agreed_exact_count|agreed_with_prediction_count|verified_count" ami/main/api/serializers.py -g'*.py'

Repository: RolnickLab/antenna

Length of output: 5077


De-duplicate Occurrence rows before counting in the Python rollup

In ami/main/api/views.py, when occurrence_filters includes the collection constraint (detections__source_image__collections), a single Occurrence can join to multiple matching Detection rows. The code at lines ~1696-1701 builds verified_occurrences from that joined queryset, and the loop at ~1717-1741 counts every SQL row from verified_occurrences.values(...), so verified_count, agreed_with_prediction_count, and agreed_exact_count become non–occurrence-weighted.

Suggested fix
-        value_fields = ["determination_id", "determination__parents_json", "_agreed_prediction_id"]
+        value_fields = ["pk", "determination_id", "determination__parents_json", "_agreed_prediction_id"]
         if include_agreement:
             # Top machine prediction's taxon for the same occurrence.
             verified_occurrences = verified_occurrences.annotate(
                 _best_machine_taxon_id=models.Subquery(
                     Classification.objects.filter(detection__occurrence=models.OuterRef("pk"))
                     .order_by(*BEST_MACHINE_PREDICTION_ORDER)
                     .values("taxon_id")[:1]
                 )
             )
             value_fields.append("_best_machine_taxon_id")

         verified_counts: dict[int, int] = {}
         agreed_with_prediction_counts: dict[int, int] = {}
         agreed_exact_counts: dict[int, int] = {}
-        for row in verified_occurrences.values(*value_fields):
+        for row in verified_occurrences.values(*value_fields).distinct():
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ami/main/api/views.py` around lines 1696 - 1701, The count loop is iterating
SQL rows from verified_occurrences (the Occurrence queryset built with variable
verified_occurrences and annotated with _agreed_prediction_id), but when
occurrence_filters includes detections__source_image__collections a single
Occurrence can join to multiple Detection rows, inflating counts; fix by
de-duplicating Occurrence rows before the Python rollup—call
verified_occurrences = verified_occurrences.distinct('pk') (or
.values_list('pk', flat=True).distinct() and re-query if you need full objects)
immediately after the annotated queryset is built and before the
values()/counting loop that computes verified_count,
agreed_with_prediction_count, and agreed_exact_count.

value_fields = ["determination_id", "determination__parents_json", "_agreed_prediction_id"]
if include_agreement:
# Top machine prediction's taxon for the same occurrence.
verified_occurrences = verified_occurrences.annotate(
_best_machine_taxon_id=models.Subquery(
Classification.objects.filter(detection__occurrence=models.OuterRef("pk"))
.order_by(*BEST_MACHINE_PREDICTION_ORDER)
.values("taxon_id")[:1]
)
)
value_fields.append("_best_machine_taxon_id")

verified_counts: dict[int, int] = {}
agreed_with_prediction_counts: dict[int, int] = {}
agreed_exact_counts: dict[int, int] = {}
for row in verified_occurrences.values(*value_fields):
determination_id = row["determination_id"]
# The taxon itself plus every ancestor — i.e. every row this occurrence rolls up to.
taxon_ids: set[int] = set()
if determination_id is not None:
taxon_ids.add(determination_id)
for parent in row["determination__parents_json"] or []:
# parents_json round-trips through the pydantic schema field, so elements
# may be dicts or ``TaxonParent`` objects depending on the query path.
parent_id = parent.get("id") if isinstance(parent, dict) else getattr(parent, "id", None)
if parent_id is not None:
taxon_ids.add(int(parent_id))

for taxon_id in taxon_ids:
verified_counts[taxon_id] = verified_counts.get(taxon_id, 0) + 1
if row["_agreed_prediction_id"] is not None:
for taxon_id in taxon_ids:
agreed_with_prediction_counts[taxon_id] = agreed_with_prediction_counts.get(taxon_id, 0) + 1
if (
include_agreement
and determination_id is not None
and determination_id == row["_best_machine_taxon_id"]
):
for taxon_id in taxon_ids:
agreed_exact_counts[taxon_id] = agreed_exact_counts.get(taxon_id, 0) + 1

def count_annotation(counts: dict[int, int]) -> models.expressions.Combinable:
if not counts:
return models.Value(0, output_field=models.IntegerField())
return models.Case(
*(models.When(id=taxon_id, then=models.Value(count)) for taxon_id, count in counts.items()),
default=models.Value(0),
output_field=models.IntegerField(),
)

qs = qs.annotate(
verified_count=count_annotation(verified_counts),
agreed_with_prediction_count=count_annotation(agreed_with_prediction_counts),
)
if include_agreement:
qs = qs.annotate(agreed_exact_count=count_annotation(agreed_exact_counts))

# verified=true|false filter (list only); verified=false is the strict complement.
if self.action == "list" and "verified" in self.request.query_params:
verified = BooleanField(required=False).clean(self.request.query_params.get("verified"))
verified_taxon_ids = list(verified_counts.keys())
if verified:
qs = qs.filter(id__in=verified_taxon_ids)
else:
qs = qs.exclude(id__in=verified_taxon_ids)

return qs

def attach_tags_by_project(self, qs: QuerySet, project: Project) -> QuerySet:
Expand Down
31 changes: 31 additions & 0 deletions ami/main/migrations/0085_taxon_parents_json_gin_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.db import migrations


class Migration(migrations.Migration):
"""
GIN index on Taxon.parents_json to support hierarchical (descendant) rollup
of the per-taxon verification / agreement counts added for issue #1316.

Without it, Family- and Order-rank rows on large projects fall back to a
seq-scan on the parents_json containment (`@>`) test and dominate query time.

CREATE INDEX CONCURRENTLY can't run inside a transaction, so this migration
is non-atomic. IF NOT EXISTS keeps it safe to co-exist with the same index if
it lands separately via the #1307 follow-up.
"""

atomic = False

dependencies = [
("main", "0084_revoke_delete_job_from_roles"),
]

operations = [
migrations.RunSQL(
sql=(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS main_taxon_parents_json_gin_idx "
"ON main_taxon USING gin (parents_json jsonb_path_ops);"
),
reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS main_taxon_parents_json_gin_idx;",
),
]
12 changes: 12 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3811,6 +3811,18 @@ def best_determination_score(self) -> float | None:
# This is handled by an annotation if we are filtering by project, deployment or event
return None

def verified_count(self) -> int | None:
# Handled by an annotation when filtering by project (TaxonViewSet.add_verification_data)
return None

def agreed_with_prediction_count(self) -> int | None:
# Handled by an annotation when filtering by project (TaxonViewSet.add_verification_data)
return None

def agreed_exact_count(self) -> int | None:
# Handled by an annotation only when with_agreement is requested or on the detail view
return None

def occurrence_images(self, limit: int | None = 10) -> list[str]:
# This is handled by an annotation if we are filtering by project, deployment or event
return []
Expand Down
129 changes: 129 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4761,3 +4761,132 @@ def test_registration_order_preserves_occurrence_retrieve(self):
retrieve_response = self.client.get(f"/api/v2/occurrences/{occurrence.pk}/?project_id={self.project.pk}")
self.assertEqual(stats_response.status_code, 200, "stats URL must resolve")
self.assertEqual(retrieve_response.status_code, 200, "occurrence retrieve must still work")


class TestTaxaVerification(APITestCase):
"""Per-taxon verification + human/model agreement annotations and the verified filter (#1316)."""

def setUp(self):
self.project, self.deployment = setup_test_project(reuse=False)
self.taxa_list = create_taxa(self.project)
self.order = Taxon.objects.get(name="Lepidoptera")
self.family = Taxon.objects.get(name="Nymphalidae")
self.genus = Taxon.objects.get(name="Vanessa")
self.cardui = Taxon.objects.get(name="Vanessa cardui")
self.atalanta = Taxon.objects.get(name="Vanessa atalanta")
self.itea = Taxon.objects.get(name="Vanessa itea")

create_captures(deployment=self.deployment, num_nights=1, images_per_night=3)
# 3 occurrences ML-determined to cardui, 1 to itea (left unverified)
create_occurrences(deployment=self.deployment, num=3, taxon=self.cardui, determination_score=0.9)
create_occurrences(deployment=self.deployment, num=1, taxon=self.itea, determination_score=0.9)

self.user = User.objects.create_user(email="verifier@insectai.org", is_staff=True, is_superuser=True)
self.client.force_authenticate(user=self.user)

cardui_occ = list(Occurrence.objects.filter(project=self.project, determination=self.cardui).order_by("pk"))
self.assertEqual(len(cardui_occ), 3)
self.occ_pred, self.occ_exact, self.occ_disagree = cardui_occ

# occ_pred: user agrees with the model prediction (cardui), agreed_with_prediction set
Identification.objects.create(
occurrence=self.occ_pred,
taxon=self.cardui,
user=self.user,
agreed_with_prediction=self.occ_pred.best_prediction,
)
# occ_exact: same taxon as the model, but not via the "agree" workflow
Identification.objects.create(occurrence=self.occ_exact, taxon=self.cardui, user=self.user)
# occ_disagree: user overrides to a different taxon (atalanta) than the model (cardui)
Identification.objects.create(occurrence=self.occ_disagree, taxon=self.atalanta, user=self.user)

self.itea_occ = Occurrence.objects.get(project=self.project, determination=self.itea)
self.list_url = f"/api/v2/taxa/?project_id={self.project.pk}&limit=1000"

def _detail(self, taxon):
res = self.client.get(f"/api/v2/taxa/{taxon.pk}/?project_id={self.project.pk}")
self.assertEqual(res.status_code, status.HTTP_200_OK)
return res.json()

def _list_by_name(self, url=None):
res = self.client.get(url or self.list_url)
self.assertEqual(res.status_code, status.HTTP_200_OK)
return {row["name"]: row for row in res.json()["results"]}

# --- verified_count (hierarchical rollup) ---

def test_verified_count_species(self):
self.assertEqual(self._detail(self.cardui)["verified_count"], 2)
self.assertEqual(self._detail(self.atalanta)["verified_count"], 1)
self.assertEqual(self._detail(self.itea)["verified_count"], 0)

def test_verified_count_rolls_up_to_ancestors(self):
# Verifying species marks genus/family/order verified, occurrence-weighted by descendants.
for ancestor in (self.genus, self.family, self.order):
self.assertEqual(self._detail(ancestor)["verified_count"], 3, ancestor.name)

# --- agreed_with_prediction_count (chosen identification only) ---

def test_agreed_with_prediction_counts_only_chosen_identification(self):
self.assertEqual(self._detail(self.cardui)["agreed_with_prediction_count"], 1)
self.assertEqual(self._detail(self.atalanta)["agreed_with_prediction_count"], 0)
# Rolls up: only occ_pred contributes under the genus.
self.assertEqual(self._detail(self.genus)["agreed_with_prediction_count"], 1)

# --- agreed_exact_count (gated) ---

def test_agreed_exact_count_on_detail(self):
# occ_pred + occ_exact: user determination == top machine prediction (cardui).
self.assertEqual(self._detail(self.cardui)["agreed_exact_count"], 2)
# occ_disagree: user picked atalanta, model said cardui → not exact.
self.assertEqual(self._detail(self.atalanta)["agreed_exact_count"], 0)
self.assertEqual(self._detail(self.genus)["agreed_exact_count"], 2)

def test_agreed_exact_count_gated_on_list(self):
rows = self._list_by_name()
self.assertIn("verified_count", rows["Vanessa cardui"])
self.assertIn("agreed_with_prediction_count", rows["Vanessa cardui"])
self.assertNotIn("agreed_exact_count", rows["Vanessa cardui"])

rows = self._list_by_name(self.list_url + "&with_agreement=true")
self.assertIn("agreed_exact_count", rows["Vanessa cardui"])
self.assertEqual(rows["Vanessa cardui"]["agreed_exact_count"], 2)

# --- list field values ---

def test_list_field_values(self):
rows = self._list_by_name()
self.assertEqual(rows["Vanessa cardui"]["occurrences_count"], 2)
self.assertEqual(rows["Vanessa cardui"]["verified_count"], 2)
self.assertEqual(rows["Vanessa cardui"]["agreed_with_prediction_count"], 1)
self.assertEqual(rows["Vanessa atalanta"]["verified_count"], 1)
self.assertEqual(rows["Vanessa itea"]["verified_count"], 0)

# --- verified=true|false filter ---

def test_verified_filter_true_false_complement(self):
all_names = set(self._list_by_name().keys())
verified = set(self._list_by_name(self.list_url + "&verified=true").keys())
unverified = set(self._list_by_name(self.list_url + "&verified=false").keys())
self.assertEqual(verified, {"Vanessa cardui", "Vanessa atalanta"})
self.assertEqual(unverified, {"Vanessa itea"})
# verified=false is the strict complement of verified=true on the filtered set.
self.assertEqual(verified | unverified, all_names)
self.assertEqual(verified & unverified, set())

def test_ordering_by_verified_count(self):
res = self.client.get(self.list_url + "&ordering=verified_count")
self.assertEqual(res.status_code, status.HTTP_200_OK)
counts = [row["verified_count"] for row in res.json()["results"]]
self.assertEqual(counts, sorted(counts))

# --- apply_defaults handling ---

def test_verified_filter_respects_apply_defaults(self):
self.project.default_filters_exclude_taxa.add(self.atalanta)

verified_default = set(self._list_by_name(self.list_url + "&verified=true").keys())
self.assertEqual(verified_default, {"Vanessa cardui"})

verified_bypassed = set(self._list_by_name(self.list_url + "&verified=true&apply_defaults=false").keys())
self.assertEqual(verified_bypassed, {"Vanessa cardui", "Vanessa atalanta"})
Loading
Loading