Skip to content
Merged
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
61 changes: 61 additions & 0 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from ami.base.views import ProjectMixin
from ami.main.api.schemas import limit_doc_param, project_id_doc_param
from ami.main.api.serializers import TagSerializer
from ami.main.models_future.identifications import create_identifications_batch, resolve_occurrences
from ami.main.models_future.occurrence import model_agreement_for_project, top_identifiers_for_project
from ami.utils.requests import get_default_classification_threshold
from ami.utils.storages import ConnectionTestResult
Expand Down Expand Up @@ -62,6 +63,8 @@
update_detection_counts,
)
from .serializers import (
BulkIdentificationRequestSerializer,
BulkIdentificationResponseSerializer,
ClassificationListSerializer,
ClassificationSerializer,
ClassificationWithTaxaSerializer,
Expand Down Expand Up @@ -2237,6 +2240,64 @@ 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.

Each item is saved independently: a bad item reports its error under its
own index in ``results`` while the rest of the batch still succeeds, so
a per-item problem never fails the request. Only a batch-level problem
(too many items, a duplicate occurrence, occurrences from more than one
project) rejects the whole request with a 400 serializer error instead
of the per-item ``results`` shape. See #1371.
"""
request_serializer = BulkIdentificationRequestSerializer(data=request.data)
request_serializer.is_valid(raise_exception=True)
items = request_serializer.validated_data["identifications"]

occurrences = resolve_occurrences(items)
self._authorize_batch(request, occurrences)
results = create_identifications_batch(items, request.user, occurrences)

created_count = sum(1 for result in results if result["status"] == "created")
return Response(
{
"created_count": created_count,
"error_count": len(items) - created_count,
"results": results,
}
)

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`.
Create permission is granted per project, so one check per batch is
equivalent to one per occurrence — see `Identification.check_permission`.
"""
if not occurrences:
# Nothing resolved, so there is nothing to authorize against or to
# write; every item is reported as not found instead.
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())))
# Check "create", not the action name: "bulk" is not in the model's CRUD
# permission map and would fall through to a permission no role grants.
if not probe.check_permission(request.user, "create"):
raise PermissionDenied("You do not have permission to identify occurrences in this project.")


class SiteViewSet(DefaultViewSet, ProjectMixin):
"""
Expand Down
8 changes: 7 additions & 1 deletion ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2912,7 +2912,13 @@ 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. The bulk
endpoint relies on that to check once per batch instead of once per
occurrence — revisit it if this path gains per-occurrence logic. See #1371.
"""
import ami.users.roles as roles

project = self.get_project()
Expand Down
185 changes: 185 additions & 0 deletions ami/main/models_future/identifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""
Batch creation of identifications.

Resolves every row a batch references in a fixed number of queries, validates
each item against the resolved batch, then saves each item inside its own
savepoint so one bad item does not abort the rest. See #1371.
"""

import logging

from django.contrib.auth.models import AbstractUser
from django.core import exceptions
from django.db import IntegrityError, transaction

from ami.main.models import Classification, Identification, Occurrence, Taxon

logger = logging.getLogger(__name__)


def resolve_occurrences(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 caller's permission check 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 create_identifications_batch(
items: list[dict],
user: AbstractUser,
occurrences: dict[int, Occurrence],
) -> list[dict]:
"""
Save one identification per item and report an outcome per item.

Returns one result dict per item, in item order: ``{index, occurrence_id,
status: "created", id}`` on success, ``{index, occurrence_id, status:
"error", errors}`` on failure. A failed item never aborts the batch.
The caller is responsible for authorization; ``occurrences`` comes from
:func:`resolve_occurrences` so the permission check and the writes see the
same rows.
"""
taxa = _resolve_taxa(items)
agreed_identifications, agreed_predictions = _resolve_agreement_targets(items)

results = []
for index, item in enumerate(items):
errors = _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=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:
# Under ATOMIC_REQUESTS this is a savepoint, so rolling it back
# discards only this item and the rest of the batch continues.
# save() also withdraws the user's earlier identification on this
# occurrence and recomputes the determination. See #1371.
with transaction.atomic():
identification.save()
except (IntegrityError, exceptions.ObjectDoesNotExist) as error:
# A referenced row (the occurrence, or something save() touches)
# was deleted between resolving the batch and writing this item.
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 identification could not be saved. "
"A related record may have been deleted. Refresh and retry."
]
},
}
)
continue

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

return results


def _resolve_taxa(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(
items: list[dict],
) -> tuple[dict[int, Identification], dict[int, Classification]]:
"""Fetch the identifications and predictions that items claim to agree with."""
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(
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
Loading
Loading