@@ -4761,3 +4761,132 @@ def test_registration_order_preserves_occurrence_retrieve(self):
47614761 retrieve_response = self .client .get (f"/api/v2/occurrences/{ occurrence .pk } /?project_id={ self .project .pk } " )
47624762 self .assertEqual (stats_response .status_code , 200 , "stats URL must resolve" )
47634763 self .assertEqual (retrieve_response .status_code , 200 , "occurrence retrieve must still work" )
4764+
4765+
4766+ class TestTaxaVerification (APITestCase ):
4767+ """Per-taxon verification + human/model agreement annotations and the verified filter (#1316)."""
4768+
4769+ def setUp (self ):
4770+ self .project , self .deployment = setup_test_project (reuse = False )
4771+ self .taxa_list = create_taxa (self .project )
4772+ self .order = Taxon .objects .get (name = "Lepidoptera" )
4773+ self .family = Taxon .objects .get (name = "Nymphalidae" )
4774+ self .genus = Taxon .objects .get (name = "Vanessa" )
4775+ self .cardui = Taxon .objects .get (name = "Vanessa cardui" )
4776+ self .atalanta = Taxon .objects .get (name = "Vanessa atalanta" )
4777+ self .itea = Taxon .objects .get (name = "Vanessa itea" )
4778+
4779+ create_captures (deployment = self .deployment , num_nights = 1 , images_per_night = 3 )
4780+ # 3 occurrences ML-determined to cardui, 1 to itea (left unverified)
4781+ create_occurrences (deployment = self .deployment , num = 3 , taxon = self .cardui , determination_score = 0.9 )
4782+ create_occurrences (deployment = self .deployment , num = 1 , taxon = self .itea , determination_score = 0.9 )
4783+
4784+ self .user = User .objects .create_user (email = "verifier@insectai.org" , is_staff = True , is_superuser = True )
4785+ self .client .force_authenticate (user = self .user )
4786+
4787+ cardui_occ = list (Occurrence .objects .filter (project = self .project , determination = self .cardui ).order_by ("pk" ))
4788+ self .assertEqual (len (cardui_occ ), 3 )
4789+ self .occ_pred , self .occ_exact , self .occ_disagree = cardui_occ
4790+
4791+ # occ_pred: user agrees with the model prediction (cardui), agreed_with_prediction set
4792+ Identification .objects .create (
4793+ occurrence = self .occ_pred ,
4794+ taxon = self .cardui ,
4795+ user = self .user ,
4796+ agreed_with_prediction = self .occ_pred .best_prediction ,
4797+ )
4798+ # occ_exact: same taxon as the model, but not via the "agree" workflow
4799+ Identification .objects .create (occurrence = self .occ_exact , taxon = self .cardui , user = self .user )
4800+ # occ_disagree: user overrides to a different taxon (atalanta) than the model (cardui)
4801+ Identification .objects .create (occurrence = self .occ_disagree , taxon = self .atalanta , user = self .user )
4802+
4803+ self .itea_occ = Occurrence .objects .get (project = self .project , determination = self .itea )
4804+ self .list_url = f"/api/v2/taxa/?project_id={ self .project .pk } &limit=1000"
4805+
4806+ def _detail (self , taxon ):
4807+ res = self .client .get (f"/api/v2/taxa/{ taxon .pk } /?project_id={ self .project .pk } " )
4808+ self .assertEqual (res .status_code , status .HTTP_200_OK )
4809+ return res .json ()
4810+
4811+ def _list_by_name (self , url = None ):
4812+ res = self .client .get (url or self .list_url )
4813+ self .assertEqual (res .status_code , status .HTTP_200_OK )
4814+ return {row ["name" ]: row for row in res .json ()["results" ]}
4815+
4816+ # --- verified_count (hierarchical rollup) ---
4817+
4818+ def test_verified_count_species (self ):
4819+ self .assertEqual (self ._detail (self .cardui )["verified_count" ], 2 )
4820+ self .assertEqual (self ._detail (self .atalanta )["verified_count" ], 1 )
4821+ self .assertEqual (self ._detail (self .itea )["verified_count" ], 0 )
4822+
4823+ def test_verified_count_rolls_up_to_ancestors (self ):
4824+ # Verifying species marks genus/family/order verified, occurrence-weighted by descendants.
4825+ for ancestor in (self .genus , self .family , self .order ):
4826+ self .assertEqual (self ._detail (ancestor )["verified_count" ], 3 , ancestor .name )
4827+
4828+ # --- agreed_with_prediction_count (chosen identification only) ---
4829+
4830+ def test_agreed_with_prediction_counts_only_chosen_identification (self ):
4831+ self .assertEqual (self ._detail (self .cardui )["agreed_with_prediction_count" ], 1 )
4832+ self .assertEqual (self ._detail (self .atalanta )["agreed_with_prediction_count" ], 0 )
4833+ # Rolls up: only occ_pred contributes under the genus.
4834+ self .assertEqual (self ._detail (self .genus )["agreed_with_prediction_count" ], 1 )
4835+
4836+ # --- agreed_exact_count (gated) ---
4837+
4838+ def test_agreed_exact_count_on_detail (self ):
4839+ # occ_pred + occ_exact: user determination == top machine prediction (cardui).
4840+ self .assertEqual (self ._detail (self .cardui )["agreed_exact_count" ], 2 )
4841+ # occ_disagree: user picked atalanta, model said cardui → not exact.
4842+ self .assertEqual (self ._detail (self .atalanta )["agreed_exact_count" ], 0 )
4843+ self .assertEqual (self ._detail (self .genus )["agreed_exact_count" ], 2 )
4844+
4845+ def test_agreed_exact_count_gated_on_list (self ):
4846+ rows = self ._list_by_name ()
4847+ self .assertIn ("verified_count" , rows ["Vanessa cardui" ])
4848+ self .assertIn ("agreed_with_prediction_count" , rows ["Vanessa cardui" ])
4849+ self .assertNotIn ("agreed_exact_count" , rows ["Vanessa cardui" ])
4850+
4851+ rows = self ._list_by_name (self .list_url + "&with_agreement=true" )
4852+ self .assertIn ("agreed_exact_count" , rows ["Vanessa cardui" ])
4853+ self .assertEqual (rows ["Vanessa cardui" ]["agreed_exact_count" ], 2 )
4854+
4855+ # --- list field values ---
4856+
4857+ def test_list_field_values (self ):
4858+ rows = self ._list_by_name ()
4859+ self .assertEqual (rows ["Vanessa cardui" ]["occurrences_count" ], 2 )
4860+ self .assertEqual (rows ["Vanessa cardui" ]["verified_count" ], 2 )
4861+ self .assertEqual (rows ["Vanessa cardui" ]["agreed_with_prediction_count" ], 1 )
4862+ self .assertEqual (rows ["Vanessa atalanta" ]["verified_count" ], 1 )
4863+ self .assertEqual (rows ["Vanessa itea" ]["verified_count" ], 0 )
4864+
4865+ # --- verified=true|false filter ---
4866+
4867+ def test_verified_filter_true_false_complement (self ):
4868+ all_names = set (self ._list_by_name ().keys ())
4869+ verified = set (self ._list_by_name (self .list_url + "&verified=true" ).keys ())
4870+ unverified = set (self ._list_by_name (self .list_url + "&verified=false" ).keys ())
4871+ self .assertEqual (verified , {"Vanessa cardui" , "Vanessa atalanta" })
4872+ self .assertEqual (unverified , {"Vanessa itea" })
4873+ # verified=false is the strict complement of verified=true on the filtered set.
4874+ self .assertEqual (verified | unverified , all_names )
4875+ self .assertEqual (verified & unverified , set ())
4876+
4877+ def test_ordering_by_verified_count (self ):
4878+ res = self .client .get (self .list_url + "&ordering=verified_count" )
4879+ self .assertEqual (res .status_code , status .HTTP_200_OK )
4880+ counts = [row ["verified_count" ] for row in res .json ()["results" ]]
4881+ self .assertEqual (counts , sorted (counts ))
4882+
4883+ # --- apply_defaults handling ---
4884+
4885+ def test_verified_filter_respects_apply_defaults (self ):
4886+ self .project .default_filters_exclude_taxa .add (self .atalanta )
4887+
4888+ verified_default = set (self ._list_by_name (self .list_url + "&verified=true" ).keys ())
4889+ self .assertEqual (verified_default , {"Vanessa cardui" })
4890+
4891+ verified_bypassed = set (self ._list_by_name (self .list_url + "&verified=true&apply_defaults=false" ).keys ())
4892+ self .assertEqual (verified_bypassed , {"Vanessa cardui" , "Vanessa atalanta" })
0 commit comments