Skip to content
Draft
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
75 changes: 75 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import collections
import datetime

from django.db.models import QuerySet
Expand Down Expand Up @@ -800,6 +801,80 @@ class Meta:
]


#: Upper bound on a single bulk identification request.
#: Every identification in a batch is written inside the request's transaction, which
#: holds row locks on each occurrence until the request finishes, so an unbounded batch
#: would block other people identifying the same occurrences for as long as it ran. The
#: identification interface can only select occurrences on the page being displayed, so
#: real batches are far smaller than this; the cap bounds the worst case rather than
#: shaping the interface.
MAX_BULK_IDENTIFICATIONS = 200


class BulkIdentificationItemSerializer(serializers.Serializer):
"""
One identification within a bulk request.

Related objects are declared as plain integers rather than
`PrimaryKeyRelatedField` so that the view can resolve the whole batch in a
fixed number of queries. `PrimaryKeyRelatedField` issues one query per field
per item, which would make validation alone scale with the size of the batch.

`withdrawn` is deliberately absent: the model maintains it, and letting a
client set it would break the "one active identification per user per
occurrence" invariant.
"""

occurrence_id = serializers.IntegerField()
taxon_id = serializers.IntegerField()
comment = serializers.CharField(required=False, allow_blank=True, default="")
agreed_with_identification_id = serializers.IntegerField(required=False, allow_null=True, default=None)
agreed_with_prediction_id = serializers.IntegerField(required=False, allow_null=True, default=None)


class BulkIdentificationRequestSerializer(serializers.Serializer):
"""Validates the shape of a bulk request, before any occurrence is looked up."""

identifications = BulkIdentificationItemSerializer(many=True, allow_empty=False)

def validate_identifications(self, value: list[dict]) -> list[dict]:
if len(value) > MAX_BULK_IDENTIFICATIONS:
raise serializers.ValidationError(
f"A single request may contain at most {MAX_BULK_IDENTIFICATIONS} identifications, "
f"got {len(value)}."
)

counts = collections.Counter(item["occurrence_id"] for item in value)
duplicates = sorted(pk for pk, count in counts.items() if count > 1)
if duplicates:
# Two identifications for one occurrence in one batch have no defined
# winner: the outcome would depend on insert ordering rather than on
# anything the client asked for.
raise serializers.ValidationError(
f"Each occurrence may appear only once per request. Repeated occurrence IDs: {duplicates}."
)

return value


class BulkIdentificationResultSerializer(serializers.Serializer):
"""The outcome of a single submitted item, matched to the request by `index`."""

index = serializers.IntegerField()
occurrence_id = serializers.IntegerField()
status = serializers.ChoiceField(choices=["created", "error"])
id = serializers.IntegerField(required=False)
errors = serializers.DictField(required=False)


class BulkIdentificationResponseSerializer(serializers.Serializer):
"""Per-item outcomes for a bulk request, in the order the items were submitted."""

created_count = serializers.IntegerField()
error_count = serializers.IntegerField()
results = BulkIdentificationResultSerializer(many=True)


class TaxonDetectionsSerializer(DefaultSerializer):
class Meta:
model = Detection
Expand Down
221 changes: 220 additions & 1 deletion ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.contrib.postgres.search import TrigramSimilarity
from django.core import exceptions
from django.core.files.storage import default_storage
from django.db import models
from django.db import DatabaseError, models, transaction
from django.db.models import OuterRef, Prefetch, Q, Subquery
from django.db.models.query import QuerySet
from django.forms import BooleanField, CharField, IntegerField
Expand Down Expand Up @@ -62,6 +62,8 @@
update_detection_counts,
)
from .serializers import (
BulkIdentificationRequestSerializer,
BulkIdentificationResponseSerializer,
ClassificationListSerializer,
ClassificationSerializer,
ClassificationWithTaxaSerializer,
Expand Down Expand Up @@ -2237,6 +2239,223 @@ def perform_create(self, serializer):

serializer.save(user=self.request.user)

@extend_schema(
request=BulkIdentificationRequestSerializer,
responses={200: BulkIdentificationResponseSerializer},
)
@action(detail=False, methods=["post"])
def bulk(self, request):
"""
Create many identifications in one request.

Applying determinations to a page of occurrences otherwise costs one HTTP
request per occurrence. Each item is saved independently, so a single bad
item (an occurrence deleted since the page was loaded, say) reports an
error against its own index while the rest of the batch still succeeds.
The response lists an outcome per submitted item, in request order.
"""
request_serializer = BulkIdentificationRequestSerializer(data=request.data)
request_serializer.is_valid(raise_exception=True)
items = request_serializer.validated_data["identifications"]

occurrences = self._resolve_occurrences(items)
self._authorize_batch(request, occurrences)
taxa = self._resolve_taxa(items)
agreed_identifications, agreed_predictions = self._resolve_agreement_targets(items)

results = []
created_count = 0
for index, item in enumerate(items):
errors = self._validate_item(item, occurrences, taxa, agreed_identifications, agreed_predictions)
if errors:
results.append(
{
"index": index,
"occurrence_id": item["occurrence_id"],
"status": "error",
"errors": errors,
}
)
continue

identification = Identification(
occurrence=occurrences[item["occurrence_id"]],
taxon=taxa[item["taxon_id"]],
user=request.user,
comment=item["comment"],
agreed_with_identification=agreed_identifications.get(item["agreed_with_identification_id"]),
agreed_with_prediction=agreed_predictions.get(item["agreed_with_prediction_id"]),
)
try:
# ATOMIC_REQUESTS wraps the whole request in a transaction, so this
# is a savepoint rather than a separate transaction. Rolling back to
# it leaves the connection usable, which is what lets the rest of the
# batch continue after one item fails. Without catching the error
# here, a single failure would abort the request and discard every
# identification already made in it.
#
# save() also withdraws the user's earlier identification on this
# occurrence and recomputes the occurrence's determination.
with transaction.atomic():
identification.save()
except (DatabaseError, exceptions.ObjectDoesNotExist) as error:
# The occurrence can be deleted between resolving the batch and
# writing it, which surfaces as an integrity error or a missing row
# part-way through save().
logger.warning(
f"Bulk identification of occurrence {item['occurrence_id']} failed and was skipped: {error}"
)
results.append(
{
"index": index,
"occurrence_id": item["occurrence_id"],
"status": "error",
"errors": {
"occurrence_id": ["This occurrence could not be identified. It may have been deleted."]
},
}
)
continue

created_count += 1
results.append(
{
"index": index,
"occurrence_id": item["occurrence_id"],
"status": "created",
"id": identification.pk,
}
)

return Response(
{
"created_count": created_count,
"error_count": len(items) - created_count,
"results": results,
}
)

def _resolve_occurrences(self, items: list[dict]) -> dict[int, Occurrence]:
"""Fetch every occurrence in the batch in one query, with its project loaded."""
occurrence_ids = {item["occurrence_id"] for item in items}
# select_related("project") lets the permission probe and the single-project
# check read the project without a query per occurrence.
return {
occurrence.pk: occurrence
for occurrence in Occurrence.objects.filter(pk__in=occurrence_ids).select_related("project")
}

def _authorize_batch(self, request, occurrences: dict[int, Occurrence]) -> None:
"""
Authorize the batch against the single project it belongs to.

A `detail=False` action never passes through `has_object_permission`, so the
check has to happen here. Permission to create an identification is granted
per project, so one check per batch is equivalent to one check per
occurrence — see the note on `Identification.check_permission`.
"""
if not occurrences:
# Nothing resolved, so there is no project to authorize against and
# nothing to write. Every item is reported as not found instead, which
# keeps a batch of one missing occurrence answering the same way as a
# batch where only some are missing.
return

projects = {occurrence.project for occurrence in occurrences.values()}
if len(projects) > 1:
raise api_exceptions.ValidationError(
{"identifications": ["All occurrences in a request must belong to the same project."]}
)

probe = Identification(occurrence=next(iter(occurrences.values())))
if not probe.check_permission(request.user, "create"):
# "create", not the name of this action: the action name is not in the
# model's CRUD permission map and would silently fall through to a
# permission that no role grants.
raise PermissionDenied("You do not have permission to identify occurrences in this project.")

def _resolve_taxa(self, items: list[dict]) -> dict[int, Taxon]:
"""Fetch every taxon in the batch in one query."""
taxon_ids = {item["taxon_id"] for item in items}
return {taxon.pk: taxon for taxon in Taxon.objects.filter(pk__in=taxon_ids)}

def _resolve_agreement_targets(
self, items: list[dict]
) -> tuple[dict[int, Identification], dict[int, Classification]]:
"""
Fetch the identifications and predictions that items claim to agree with.

Both are recorded only as provenance, but they still have to be real and
belong to the occurrence being identified.
"""
identification_ids = {
item["agreed_with_identification_id"] for item in items if item["agreed_with_identification_id"]
}
prediction_ids = {item["agreed_with_prediction_id"] for item in items if item["agreed_with_prediction_id"]}

agreed_identifications = {}
if identification_ids:
agreed_identifications = {
identification.pk: identification
for identification in Identification.objects.filter(pk__in=identification_ids)
}

agreed_predictions = {}
if prediction_ids:
agreed_predictions = {
classification.pk: classification
for classification in Classification.objects.filter(pk__in=prediction_ids).select_related("detection")
}

return agreed_identifications, agreed_predictions

def _validate_item(
self,
item: dict,
occurrences: dict[int, Occurrence],
taxa: dict[int, Taxon],
agreed_identifications: dict[int, Identification],
agreed_predictions: dict[int, Classification],
) -> dict[str, list[str]]:
"""Check one item against the already-fetched batch. Returns field errors, empty when valid."""
errors: dict[str, list[str]] = {}

occurrence = occurrences.get(item["occurrence_id"])
if occurrence is None:
errors["occurrence_id"] = [f"Occurrence {item['occurrence_id']} does not exist."]

if item["taxon_id"] not in taxa:
errors["taxon_id"] = [f"Taxon {item['taxon_id']} does not exist."]

agreed_identification_id = item["agreed_with_identification_id"]
if agreed_identification_id:
agreed = agreed_identifications.get(agreed_identification_id)
if agreed is None:
errors["agreed_with_identification_id"] = [
f"Identification {agreed_identification_id} does not exist."
]
elif occurrence is not None and agreed.occurrence_id != occurrence.pk:
errors["agreed_with_identification_id"] = [
"An identification can only agree with another identification of the same occurrence."
]

agreed_prediction_id = item["agreed_with_prediction_id"]
if agreed_prediction_id:
prediction = agreed_predictions.get(agreed_prediction_id)
if prediction is None:
errors["agreed_with_prediction_id"] = [f"Classification {agreed_prediction_id} does not exist."]
elif occurrence is not None and (
# A classification keeps its row when its detection is deleted, so the
# link back to an occurrence can be missing entirely.
prediction.detection is None
or prediction.detection.occurrence_id != occurrence.pk
):
errors["agreed_with_prediction_id"] = [
"An identification can only agree with a prediction of the same occurrence."
]

return errors


class SiteViewSet(DefaultViewSet, ProjectMixin):
"""
Expand Down
10 changes: 9 additions & 1 deletion ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2912,7 +2912,15 @@ def delete(self, *args, **kwargs):
update_occurrence_determination(self.occurrence, current_determination=self.taxon)

def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool:
"""Custom permission check logic for Identification model."""
"""
Custom permission check logic for Identification model.

The "create" branch depends only on the occurrence's project, never on the
occurrence itself. The bulk identification endpoint relies on that: it checks
"create" once for the whole batch instead of once per occurrence. Adding
per-occurrence logic to the "create" path means that endpoint has to check
each occurrence individually.
"""
import ami.users.roles as roles

project = self.get_project()
Expand Down
Loading