Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
45c1d77
feat: added celery export occurrence task
mohamedelabbas1996 Feb 17, 2025
f6871ea
feat: added export & export_status endpoints
mohamedelabbas1996 Feb 17, 2025
b3e448d
added migration files
mohamedelabbas1996 Feb 17, 2025
bb745f6
fixed migration conflict
mohamedelabbas1996 Feb 17, 2025
518b8df
fix: disabled pagination for export action
mohamedelabbas1996 Feb 18, 2025
b3b4369
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Feb 18, 2025
8d98759
fix: merged migrations
mohamedelabbas1996 Feb 18, 2025
21470b9
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Feb 23, 2025
a8673af
feat: added DataExport Job Type
mohamedelabbas1996 Feb 24, 2025
523d177
Implemented JSON export for occurrence data
mohamedelabbas1996 Mar 4, 2025
ac7cfbc
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Mar 4, 2025
04ab2cf
feat: Added support for csv file format
mohamedelabbas1996 Mar 4, 2025
e4599b9
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Mar 6, 2025
94cc7a3
chore: Moved export actions to a separate view under the exports app
mohamedelabbas1996 Mar 6, 2025
ed3960a
Merge branch 'main' of github.com:RolnickLab/antenna into feat/export…
mihow Mar 7, 2025
c4c9820
chore: ignore unresolvable type errors
mihow Mar 7, 2025
5dbc002
chore: remove dependencies for darwincore export in this PR
mihow Mar 7, 2025
a86a348
fix: use mixin for get_active_project
mihow Mar 7, 2025
57c5905
feat: register export views in api router
mihow Mar 7, 2025
e0df304
feat: Implemented Data Export Framework & Occurrence Exports
mohamedelabbas1996 Mar 10, 2025
8be00cd
Merge branch 'feat/export-occurrences-data' of https://github.com/Rol…
mohamedelabbas1996 Mar 10, 2025
b297a84
feat: Added more fields to the OccurrenceTabularSerializer
mohamedelabbas1996 Mar 11, 2025
d8d3b5d
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Mar 11, 2025
1270fd1
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Mar 17, 2025
44c3ca8
Refactor DataExport Model and API & Admin Integration
mohamedelabbas1996 Mar 17, 2025
95e6e86
Merge branch 'feat/export-occurrences-data' of https://github.com/Rol…
mohamedelabbas1996 Mar 17, 2025
8a02b3d
Removed DataExport status field
mohamedelabbas1996 Mar 17, 2025
c8f5d3e
chore: Raise NotImplemented for abstract methods
mohamedelabbas1996 Mar 17, 2025
349925a
Brought back DataExport file_url field
mohamedelabbas1996 Mar 17, 2025
a2bcd45
feat: setup view for listing exports
annavik Mar 19, 2025
e0321bd
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Mar 19, 2025
45485b2
Refactor Data Export: Improve Filtering, Naming, and JSON Validity
mohamedelabbas1996 Mar 20, 2025
4105177
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Mar 20, 2025
3c9aca2
Merge branch 'feat/export-occurrences-data' of https://github.com/Rol…
mohamedelabbas1996 Mar 20, 2025
543a142
fix: Added missing migration file
mohamedelabbas1996 Mar 20, 2025
95745d7
fix: Added missing migration file
mohamedelabbas1996 Mar 20, 2025
25896b7
Merge branch 'main' into feat/export-occurrences-data
annavik Mar 21, 2025
2b2bccf
Merge branch 'feat/export-occurrences-data' into feat/export-ui
annavik Mar 21, 2025
e1d5f18
fix: adjust exports view after layout updates
annavik Mar 21, 2025
6b49f5b
feat: make it possible to register new exports
annavik Mar 21, 2025
f14653f
fix: tweak labels to be sentence case
annavik Mar 21, 2025
43e8835
fix: update CSV export field from verification -> verification_status
annavik Mar 21, 2025
0cadbc9
Merge branch 'feat/export-occurrences-data' into feat/export-ui
annavik Mar 21, 2025
e529063
feat: add status column and replace progress with result on success
annavik Mar 21, 2025
af40978
feat: update job details with more information
annavik Mar 21, 2025
f836bfa
Improve DataExport handling, filtering, and cleanup logic
mohamedelabbas1996 Mar 24, 2025
3d0514a
Merge branch 'feat/export-occurrences-data' of https://github.com/Rol…
mohamedelabbas1996 Mar 24, 2025
ed5c45d
Merge branch 'feat/export-occurrences-data' into feat/export-ui
annavik Mar 24, 2025
5f8819f
fix: tweak columns and logic after backend updates
annavik Mar 24, 2025
1e879e4
test: multiple methods of nesting related obj data for exports
mihow Mar 25, 2025
4d48622
feat: return absolute urls for export files
mihow Mar 25, 2025
747708c
Merge branch 'main' into feat/export-occurrences-data
mihow Mar 25, 2025
004aee6
style: highlight required fields
annavik Mar 26, 2025
2478789
Merge branch 'main' into feat/export-occurrences-data
mohamedelabbas1996 Mar 28, 2025
cd2f57c
Refactor Export Logic and Add Export Stats
mohamedelabbas1996 Mar 28, 2025
4bae6c7
Merge branch 'feat/export-occurrences-data' of https://github.com/Rol…
mohamedelabbas1996 Mar 28, 2025
a13e327
Merge branch 'feat/export-occurrences-data' into feat/export-ui
mihow Mar 28, 2025
c5075e8
Merge branch 'feat/export-occurrences-data' into feat/export-ui
annavik Mar 31, 2025
942a2e8
chore: update table view after backend changes
annavik Mar 31, 2025
1033962
feat: setup export detail view
annavik Mar 31, 2025
3b4c753
feat: add link from job to export
annavik Mar 31, 2025
3a69165
Merge branch 'feat/export-ui' of https://github.com/RolnickLab/antenn…
annavik Mar 31, 2025
26181d0
Enhance Export Details
mohamedelabbas1996 Mar 31, 2025
dd03e6b
Merge branch 'feat/export-occurrences-data' into feat/export-ui
mohamedelabbas1996 Mar 31, 2025
249820a
chore: move hard coded strings to language file
annavik Apr 1, 2025
200e700
layout: add id column to collection table and simplify how export fil…
annavik Apr 1, 2025
003188e
perf: only poll export and collection endpoints if there is a job in …
annavik Apr 1, 2025
5d7d0cf
Merge branch 'feat/export-ui' of https://github.com/RolnickLab/antenn…
annavik Apr 1, 2025
9fdf0f8
fix: adjust labels for file size and progress
annavik Apr 1, 2025
eded961
fix: make summary count consistent with exports
mihow Apr 1, 2025
44dda2c
feat: use real name of collection or other model if available
mihow Apr 1, 2025
cfdc4d7
fix: fallback to plain `filters` if `filters_display` not available
mihow Apr 1, 2025
ca5008d
fix: update fields for DataExport admin page
mihow Apr 1, 2025
02dd4b7
feat: update and return total record count before starting export
mihow Apr 1, 2025
8b312e2
Merge branch 'feat/export-occurrences-data' of github.com:RolnickLab/…
mihow Apr 1, 2025
74cbe46
fix: clarify the number of records field on the export model
mihow Apr 2, 2025
058f93e
feat: update total record count before exporting first batch
mihow Apr 2, 2025
e323ca1
Merge branch 'feat/export-occurrences-data' of github.com:RolnickLab/…
mihow Apr 2, 2025
bc4d6a1
fix: handle exports where job has been deleted
annavik Apr 2, 2025
62c08fc
fix: spelling typo
mihow Apr 2, 2025
b20a851
feat: lower batch size for exports to increase update frequency
mihow Apr 2, 2025
a518a74
chore: reset all migrations to main
mihow Apr 3, 2025
0b06579
chore: recreate migrations
mihow Apr 3, 2025
ee34d2c
chore: moved export format validation logic to the serializer
mohamedelabbas1996 Apr 4, 2025
0900bb0
chore: changed collection filter param name to collection_id
mohamedelabbas1996 Apr 4, 2025
a1eb605
Merge branch 'feat/export-occurrences-data' of https://github.com/Rol…
mohamedelabbas1996 Apr 4, 2025
ce53563
fix: update collection filter param key
annavik Apr 8, 2025
289adfe
Merge branch 'feat/export-occurrences-data' into feat/export-ui
annavik Apr 8, 2025
8f5d9da
fix: update filters display after backend changes
annavik Apr 8, 2025
faeb081
Merge branch 'main' of github.com:RolnickLab/antenna into feat/export…
mihow Apr 8, 2025
6a50eed
chore: fix type hints
mihow Apr 8, 2025
4dadb35
Merge branch 'feat/export-occurrences-data' into feat/export-ui
mihow Apr 8, 2025
4c9e3ce
chore: rename simple JSON exporter
mihow Apr 8, 2025
7e4a946
fix: key names in display filters
mihow Apr 8, 2025
10ba10b
fix: show raw filters in the UI for now
mihow Apr 8, 2025
97e63a8
fix: use new filter name when filtering occurrences
mihow Apr 9, 2025
620bb3c
fix: filter by valid occurrences in default queryset
mihow Apr 9, 2025
4dcc8ea
Merge github.com:RolnickLab/ami-platform into feat/export-ui
mihow Apr 9, 2025
5570374
fix: export format name in initial migration
mihow Apr 9, 2025
5a86c29
fix: data export format names & filter in tests
mihow Apr 9, 2025
f0c0b14
fix: update export tests so num occurrences is greater than zero
mihow Apr 9, 2025
e80cb0b
fix: add checks to tests, fix type hints
mihow Apr 9, 2025
fea0c05
fix: ensure admin user is available for tests
mihow Apr 9, 2025
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
6 changes: 3 additions & 3 deletions ami/exports/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ class DataExportAdmin(admin.ModelAdmin):
list_display = ("id", "user", "format", "status_display", "project", "created_at", "get_job")
list_filter = ("format", "project")
search_fields = ("user__username", "format", "project__name")
readonly_fields = ("status_display", "file_url_display")
readonly_fields = ("status_display", "file_url_display", "filters_display", "created_at", "updated_at")

fieldsets = (
(
None,
{
"fields": ("user", "format", "project", "filters"),
"fields": ("user", "format", "project", "filters", "filters_display"),
},
),
(
Expand All @@ -39,7 +39,7 @@ def get_queryset(self, request: HttpRequest):

@admin.display(description="Status")
def status_display(self, obj):
return obj.status # Calls the @property from the model
return obj.job.status if obj.job else "No Job"

@admin.display(description="File URL")
def file_url_display(self, obj):
Expand Down
6 changes: 4 additions & 2 deletions ami/exports/format_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def get_serializer_class(self):

def get_queryset(self):
return (
Occurrence.objects.filter(project=self.project)
Occurrence.objects.valid() # type: ignore[union-attr] Custom manager method
.filter(project=self.project)
.select_related(
"determination",
"deployment",
Expand Down Expand Up @@ -127,7 +128,8 @@ class CSVExporter(BaseExporter):

def get_queryset(self):
return (
Occurrence.objects.filter(project=self.project)
Occurrence.objects.valid() # type: ignore[union-attr] Custom queryset method
.filter(project=self.project)
.select_related(
"determination",
"deployment",
Expand Down
2 changes: 1 addition & 1 deletion ami/exports/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Migration(migrations.Migration):
"format",
models.CharField(
choices=[
("occurrences_simple_json", "occurrences_simple_json"),
("occurrences_api_json", "occurrences_api_json"),
("occurrences_simple_csv", "occurrences_simple_csv"),
],
max_length=255,
Expand Down
12 changes: 8 additions & 4 deletions ami/exports/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ def get_filters_display(self):
from django.apps import apps

related_models = {
"collection": "main.SourceImageCollection",
"taxa_list": "main.TaxaList",
"collection_id": "main.SourceImageCollection",
"taxa_list_id": "main.TaxaList",
}
filters = self.filters or {}
filters_display = {}
Expand All @@ -53,7 +53,9 @@ def get_filters_display(self):
try:
Model = apps.get_model(model_path)
instance = Model.objects.get(pk=value)
filters_display[key] = {"id": value, "name": str(instance)}
name = getattr(instance, "name", str(instance))
key = key.replace("_id", "") # Could potentially use the field name of the relationship instead
filters_display[key] = {"id": value, "name": name}
except Model.DoesNotExist:
filters_display[key] = {"id": value, "name": f"{model_path} with id {value} not found"}
except Exception as e:
Expand All @@ -67,7 +69,9 @@ def generate_filename(self):
"""Generates a slugified filename using project name and export ID."""
from ami.exports.registry import ExportRegistry

extension = ExportRegistry.get_exporter(self.format).file_format
registry = ExportRegistry.get_exporter(self.format)
assert registry, f"Export format '{self.format}' not found in registry"
extension = registry.file_format
project_slug = slugify(self.project.name) # Convert project name to a slug
return f"{project_slug}_export-{self.pk}.{extension}"

Expand Down
2 changes: 1 addition & 1 deletion ami/exports/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ def get_supported_formats(cls):
return list(cls._registry.keys())


ExportRegistry.register("occurrences_simple_json")(format_types.JSONExporter)
ExportRegistry.register("occurrences_api_json")(format_types.JSONExporter)
ExportRegistry.register("occurrences_simple_csv")(format_types.CSVExporter)
75 changes: 56 additions & 19 deletions ami/exports/tests.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,43 @@
import csv
import json
import logging

from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.test import TestCase
from rest_framework.test import APIClient

from ami.exports.models import DataExport
from ami.main.models import Deployment, Occurrence, Project, SourceImageCollection
from ami.tests.fixtures.main import create_captures
from ami.users.models import User
from ami.main.models import Occurrence, SourceImageCollection
from ami.tests.fixtures.main import (
create_captures,
create_occurrences,
create_taxa,
group_images_into_events,
setup_test_project,
)

logger = logging.getLogger(__name__)


class DataExportTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(email="testuser@insectai.org", is_superuser=True, is_staff=True)
self.project = Project.objects.create(name="Test Project")
self.project, self.deployment = setup_test_project(reuse=False)
self.user = self.project.owner
self.assertIsNotNone(self.user, "Project owner should not be None.")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
# Create test deployment
self.deployment = Deployment.objects.create(name="Test Deployment", project=self.project)
# Create captures for the deployment
create_captures(deployment=self.deployment, num_nights=2, images_per_night=10, interval_minutes=1)
# Create captures & occurrences to test exporting
create_captures(deployment=self.deployment, num_nights=2, images_per_night=4, interval_minutes=1)
group_images_into_events(self.deployment)
create_taxa(self.project)
create_occurrences(num=10, deployment=self.deployment)
# Assert project has occurrences
self.assertGreater(self.project.occurrences.count(), 0, "No occurrences created for testing.")
# Create a collection using the provided method
self.collection = self._create_collection()
# Define export formats
self.export_formats = ["occurrences_simple_csv", "occurrences_simple_json"]
self.export_formats = ["occurrences_simple_csv", "occurrences_api_json"]

def _create_export_with_file(self, format_type):
filename = f"exports/test_export_file_{format_type}.json"
Expand Down Expand Up @@ -54,14 +66,20 @@ def test_file_is_deleted_when_export_is_deleted(self):

def _create_collection(self):
"""Create a SourceImageCollection from deployment captures."""
images = self.deployment.captures.all()
images = self.project.captures.all()
# Use only half of the images for the collection
collection_images = images[: images.count() // 2]

# Ensure collection images are fewer than total images
self.assertGreater(len(collection_images), 0, "No collection images to test exports.")
self.assertLess(len(collection_images), images.count(), "Collection images should be fewer than total images.")

# Create the collection
collection = SourceImageCollection.objects.create(
name="Test Manual Source Image Collection",
project=self.project,
method="manual",
kwargs={"image_ids": [image.pk for image in images]},
kwargs={"image_ids": [image.pk for image in collection_images]},
)
collection.save()

Expand All @@ -76,7 +94,7 @@ def run_and_validate_export(self, format_type):
user=self.user,
project=self.project,
format=format_type,
filters={"collection": self.collection.pk},
filters={"collection_id": self.collection.pk},
job=None,
)

Expand All @@ -92,29 +110,48 @@ def run_and_validate_export(self, format_type):
with default_storage.open(file_path, "r") as f:
if format_type == "occurrences_simple_csv":
self.validate_csv_records(f)
elif format_type == "occurrences_simple_json":
elif format_type == "occurrences_api_json":
self.validate_json_records(f)

# Clean up the exported file after the test
default_storage.delete(file_path)

def test_export_record_count(self):
"""Test record count in the exported file."""
for format_type in self.export_formats:
with self.subTest(format=format_type):
self.run_and_validate_export(format_type)

def validate_record_count(self, record_count):
"""Validate record count in the exported file."""
collection_count = (
Occurrence.objects.valid() # type: ignore[union-attr] # Custom queryset method
.filter(detections__source_image__collections=self.collection)
.distinct()
.count()
)
total_count = Occurrence.objects.valid().filter(project=self.project).count() # type: ignore[union-attr]

logger.debug(f"Exported: {record_count}, # in Collection: {collection_count}, # in Project: {total_count}")
self.assertGreater(record_count, 0, "Record count should be greater than zero.")
self.assertLess(record_count, total_count, "Record count should be less than total occurrences.")
self.assertEqual(record_count, collection_count, "Record count does not match expected count.")

def validate_csv_records(self, file):
"""Validate record count in CSV."""
csv_reader = csv.DictReader(file)
row_count = sum(1 for row in csv_reader)
expected_count = Occurrence.objects.filter(detections__source_image__collections=self.collection).count()
self.assertEqual(row_count, expected_count)
self.validate_record_count(row_count)

def validate_json_records(self, file):
"""Validate record count in JSON."""
data = json.load(file)
expected_count = Occurrence.objects.filter(detections__source_image__collections=self.collection).count()
self.assertEqual(len(data), expected_count)
self.validate_record_count(len(data))

def test_csv_export_record_count(self):
"""Test CSV export record count."""
self.run_and_validate_export("occurrences_simple_csv")

def test_json_export_record_count(self):
"""Test JSON export record count."""
self.run_and_validate_export("occurrences_simple_json")
self.run_and_validate_export("occurrences_api_json")
14 changes: 9 additions & 5 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -809,9 +809,9 @@ class CustomTaxonFilter(filters.BaseFilterBackend):

query_params = ["taxon"]

def get_filter_taxon(self, request: Request) -> Taxon | None:
def get_filter_taxon(self, request: Request, query_params: list[str] | None = None) -> Taxon | None:
taxon_id = None
for param in self.query_params:
for param in query_params or self.query_params:
taxon_id = request.query_params.get(param)
if taxon_id:
break
Expand Down Expand Up @@ -846,7 +846,7 @@ class CustomOccurrenceDeterminationFilter(CustomTaxonFilter):
query_params = ["determination", "taxon"]

def filter_queryset(self, request, queryset, view):
taxon = self.get_filter_taxon(request)
taxon = self.get_filter_taxon(request, query_params=self.query_params)
if taxon:
# Here the queryset is the Occurrence queryset
return queryset.filter(
Expand All @@ -861,10 +861,14 @@ class OccurrenceCollectionFilter(filters.BaseFilterBackend):
Filter occurrences by the collection their detections source images belong to.
"""

query_param = "collection"
query_params = ["collection_id", "collection"] # @TODO remove "collection" param when UI is updated

def filter_queryset(self, request, queryset, view):
collection_id = IntegerField(required=False).clean(request.query_params.get(self.query_param))
collection_id = None
for param in self.query_params:
collection_id = IntegerField(required=False).clean(request.query_params.get(param))
if collection_id:
break
if collection_id:
# Here the queryset is the Occurrence queryset
return queryset.filter(detections__source_image__collections=collection_id)
Expand Down
3 changes: 3 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ class Project(BaseModel):
image = models.ImageField(upload_to="projects", blank=True, null=True)
owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="projects")
members = models.ManyToManyField(User, related_name="user_projects", blank=True)

# Backreferences for type hinting
captures: models.QuerySet["SourceImage"]
deployments: models.QuerySet["Deployment"]
events: models.QuerySet["Event"]
occurrences: models.QuerySet["Occurrence"]
Expand All @@ -138,6 +140,7 @@ class Project(BaseModel):
devices: models.QuerySet["Device"]
sites: models.QuerySet["Site"]
jobs: models.QuerySet["Job"]

objects = ProjectManager()

def get_project(self):
Expand Down
45 changes: 25 additions & 20 deletions ami/tests/fixtures/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from ami.main.models import (
Deployment,
Detection,
Event,
Occurrence,
Project,
SourceImage,
Expand Down Expand Up @@ -100,12 +99,15 @@ def create_test_project(name: str | None) -> Project:
short_id = uuid.uuid4().hex[:8]
name = name or f"Test Project {short_id}"

admin_user = User.objects.filter(is_superuser=True).first()
project = Project.objects.create(name=name, owner=admin_user, description="Test description")
data_source = create_storage_source(project, f"Test Data Source {short_id}", prefix=f"{short_id}")
create_deployment(project, data_source, f"Test Deployment {short_id}")
create_processing_service(project)
return project
with transaction.atomic():
admin_user, _ = User.objects.get_or_create(
email=f"antenna+{short_id}@insectai.org", is_superuser=True, is_staff=True
)
project = Project.objects.create(name=name, owner=admin_user, description="Test description")
data_source = create_storage_source(project, f"Test Data Source {short_id}", prefix=f"{short_id}")
create_deployment(project, data_source, f"Test Deployment {short_id}")
create_processing_service(project)
return project


def setup_test_project(reuse=True) -> tuple[Project, Deployment]:
Expand Down Expand Up @@ -349,25 +351,28 @@ def create_occurrences(
num: int = 6,
taxon: Taxon | None = None,
):
event = Event.objects.filter(deployment=deployment).first()
if not event:
raise ValueError("No events found for deployment")

for i in range(num):
# Every Occurrence requires a Detection
source_image = SourceImage.objects.filter(event=event).order_by("?").first()
if not source_image:
raise ValueError("No source images found for event")
taxon = taxon or Taxon.objects.filter(projects=deployment.project).order_by("?").first()
# Get all source images for the deployment that have an event
source_images = list(SourceImage.objects.filter(deployment=deployment))
if not source_images:
raise ValueError("No source images with events found for deployment")

# Get taxon if not provided
if not taxon:
taxon = Taxon.objects.filter(projects=deployment.project).order_by("?").first()
if not taxon:
raise ValueError("No taxa found for project")

# Create occurrences evenly distributed across all source images
for i in range(num):
# Select images in a round-robin fashion
source_image = source_images[i % len(source_images)]

detection = Detection.objects.create(
source_image=source_image,
timestamp=source_image.timestamp, # @TODO this should be automatically set to the source image timestamp
timestamp=source_image.timestamp,
bbox=[0.1, 0.1, 0.2, 0.2],
)
# Could speed this up by creating an Occurrence with a determined taxon directly
# but this tests more of the code.

detection.classifications.create(
taxon=taxon,
score=0.9,
Expand Down
2 changes: 2 additions & 0 deletions ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Occurrences } from 'pages/occurrences/occurrences'
import { Collections } from 'pages/project/collections/collections'
import { Devices } from 'pages/project/entities/devices'
import { Sites } from 'pages/project/entities/sites'
import { Exports } from 'pages/project/exports/exports'
import { General } from 'pages/project/general/general'
import { Pipelines } from 'pages/project/pipelines/pipelines'
import { ProcessingServices } from 'pages/project/processing-services/processing-services'
Expand Down Expand Up @@ -106,6 +107,7 @@ export const App = () => (
<Route path="summary" element={<Summary />} />
<Route path="collections" element={<Collections />} />
<Route path="collections/:id" element={<CollectionDetails />} />
<Route path="exports/:id?" element={<Exports />} />
<Route
path="processing-services/:id?"
element={<ProcessingServices />}
Expand Down
Loading