|
10 | 10 | from ami.base.serializers import reverse_with_params |
11 | 11 | from ami.main.models import ( |
12 | 12 | Classification, |
| 13 | + Deployment, |
13 | 14 | Detection, |
| 15 | + Event, |
14 | 16 | Project, |
15 | 17 | SourceImage, |
16 | 18 | SourceImageCollection, |
@@ -1404,3 +1406,110 @@ def test_update_state_returns_none_when_state_genuinely_missing(self): |
1404 | 1406 | # Do NOT call initialize_job — the total key doesn't exist. |
1405 | 1407 | progress = self.manager.update_state({"img1", "img2"}, "process") |
1406 | 1408 | self.assertIsNone(progress) |
| 1409 | + |
| 1410 | + |
| 1411 | +class TestSaveResultsRefreshesDeploymentCounts(TestCase): |
| 1412 | + """save_results must refresh Deployment cached counts, not just Event counts. |
| 1413 | +
|
| 1414 | + Reproduces the "Station counts for occurrences and taxa are not always |
| 1415 | + getting updated" report: prior to the fix, save_results refreshed |
| 1416 | + update_calculated_fields_for_events but never the parent Deployment, so |
| 1417 | + deployment.occurrences_count / taxa_count stayed at the pre-job value |
| 1418 | + until something else (a manual deployment.save) ran. |
| 1419 | + """ |
| 1420 | + |
| 1421 | + def setUp(self): |
| 1422 | + self.project = Project.objects.create(name="Refresh Counts Project") |
| 1423 | + self.deployment = Deployment.objects.create(name="d1", project=self.project) |
| 1424 | + event_time = datetime.datetime(2026, 4, 16, 22, 0, 0) |
| 1425 | + self.event = Event.objects.create( |
| 1426 | + project=self.project, |
| 1427 | + deployment=self.deployment, |
| 1428 | + group_by="2026-04-16", |
| 1429 | + start=event_time, |
| 1430 | + end=event_time, |
| 1431 | + ) |
| 1432 | + self.image = SourceImage.objects.create( |
| 1433 | + deployment=self.deployment, |
| 1434 | + project=self.project, |
| 1435 | + event=self.event, |
| 1436 | + timestamp=event_time, |
| 1437 | + path="refresh_counts_test.jpg", |
| 1438 | + ) |
| 1439 | + self.collection = SourceImageCollection.objects.create(project=self.project, name="c") |
| 1440 | + self.collection.images.add(self.image) |
| 1441 | + |
| 1442 | + self.pipeline = Pipeline.objects.create(name="Refresh Counts Pipeline (Random)") |
| 1443 | + self.algorithms = { |
| 1444 | + key: get_or_create_algorithm_and_category_map(val) for key, val in ALGORITHM_CHOICES.items() |
| 1445 | + } |
| 1446 | + self.pipeline.algorithms.set( |
| 1447 | + [ |
| 1448 | + self.algorithms["random-detector"], |
| 1449 | + self.algorithms["random-binary-classifier"], |
| 1450 | + self.algorithms["random-species-classifier"], |
| 1451 | + ] |
| 1452 | + ) |
| 1453 | + |
| 1454 | + self.deployment.update_calculated_fields(save=True) |
| 1455 | + self.deployment.refresh_from_db() |
| 1456 | + self.assertEqual(self.deployment.occurrences_count, 0) |
| 1457 | + self.assertEqual(self.deployment.taxa_count, 0) |
| 1458 | + |
| 1459 | + def _fake_results(self): |
| 1460 | + detector = ALGORITHM_CHOICES["random-detector"] |
| 1461 | + binary_classifier = ALGORITHM_CHOICES["random-binary-classifier"] |
| 1462 | + species_classifier = ALGORITHM_CHOICES["random-species-classifier"] |
| 1463 | + assert binary_classifier.category_map and species_classifier.category_map |
| 1464 | + |
| 1465 | + detection = DetectionResponse( |
| 1466 | + source_image_id=self.image.pk, |
| 1467 | + bbox=BoundingBox(x1=0.0, y1=0.0, x2=1.0, y2=1.0), |
| 1468 | + inference_time=0.1, |
| 1469 | + algorithm=AlgorithmReference(name=detector.name, key=detector.key), |
| 1470 | + timestamp=self.image.timestamp, |
| 1471 | + classifications=[ |
| 1472 | + ClassificationResponse( |
| 1473 | + classification=binary_classifier.category_map.labels[0], |
| 1474 | + labels=binary_classifier.category_map.labels, |
| 1475 | + scores=[0.95], |
| 1476 | + algorithm=AlgorithmReference(name=binary_classifier.name, key=binary_classifier.key), |
| 1477 | + timestamp=self.image.timestamp, |
| 1478 | + terminal=False, |
| 1479 | + ), |
| 1480 | + ClassificationResponse( |
| 1481 | + classification=species_classifier.category_map.labels[0], |
| 1482 | + labels=species_classifier.category_map.labels, |
| 1483 | + scores=[0.85], |
| 1484 | + algorithm=AlgorithmReference(name=species_classifier.name, key=species_classifier.key), |
| 1485 | + timestamp=self.image.timestamp, |
| 1486 | + terminal=True, |
| 1487 | + ), |
| 1488 | + ], |
| 1489 | + ) |
| 1490 | + return PipelineResultsResponse( |
| 1491 | + pipeline=self.pipeline.slug, |
| 1492 | + algorithms={ |
| 1493 | + detector.key: detector, |
| 1494 | + binary_classifier.key: binary_classifier, |
| 1495 | + species_classifier.key: species_classifier, |
| 1496 | + }, |
| 1497 | + total_time=0.01, |
| 1498 | + source_images=[SourceImageResponse(id=self.image.pk, url=self.image.path)], |
| 1499 | + detections=[detection], |
| 1500 | + ) |
| 1501 | + |
| 1502 | + def test_deployment_counts_refresh_after_save_results(self): |
| 1503 | + save_results(self._fake_results()) |
| 1504 | + |
| 1505 | + self.deployment.refresh_from_db() |
| 1506 | + self.assertGreater( |
| 1507 | + self.deployment.occurrences_count, |
| 1508 | + 0, |
| 1509 | + "Deployment.occurrences_count should reflect occurrences created by save_results", |
| 1510 | + ) |
| 1511 | + self.assertGreater( |
| 1512 | + self.deployment.taxa_count, |
| 1513 | + 0, |
| 1514 | + "Deployment.taxa_count should reflect taxa from occurrences created by save_results", |
| 1515 | + ) |
0 commit comments