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
12 changes: 12 additions & 0 deletions api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rest_framework import permissions

from tigacrafting.models import IdentificationTask, ExpertReportAnnotation, UserStat
from tigacrafting.permissions import ReviewPermission
from tigaserver_app.models import TigaUser, Notification

from .utils import get_fk_fieldnames
Expand Down Expand Up @@ -252,6 +253,17 @@ def has_permission(self, request, view):
obj_or_klass=ExpertReportAnnotation
)

class IdentificationTaskReviewPermissions(IsRegularUser):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False

return UserRolePermission().check_permissions(
user=request.user,
action='add',
obj_or_klass=ReviewPermission
)

class BaseIdentificationTaskAttributePermissions(BaseIdentificationTaskPermissions):
pass

Expand Down
107 changes: 100 additions & 7 deletions api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,13 +1002,13 @@ def validate(self, data):
try:
data['report'] = Report.objects.get(type='adult', pk=self.context.get('observation_uuid'))
except Report.DoesNotExist:
raise serializers.ValidationError("The observation does not does not exist.")
raise serializers.ValidationError("The observation does not exist.")

if best_photo_uuid := data.pop('best_photo_uuid', None):
try:
data['best_photo'] = Photo.objects.get(report=data['report'], uuid=best_photo_uuid)
except Photo.DoesNotExist:
raise serializers.ValidationError("The photo does not does not exist or does not belong to the observation.")
raise serializers.ValidationError("The photo does not exist or does not belong to the observation.")

is_flagged = data.pop("is_flagged")
is_visible = data.pop("is_visible", self.ObservationFlagsSerializer().fields['is_visible'].default)
Expand Down Expand Up @@ -1150,7 +1150,7 @@ class Meta(BaseAssignmentSerializer.Meta):

class IdentificationTaskSerializer(serializers.ModelSerializer):
class IdentificationTaskReviewSerializer(serializers.ModelSerializer):
type = serializers.ChoiceField(source='review_type',choices=IdentificationTask.Review.choices)
action = serializers.ChoiceField(source='review_type',choices=IdentificationTask.Review.choices)

def to_representation(self, instance):
if self.allow_null and instance.review_type is None:
Expand All @@ -1160,7 +1160,7 @@ def to_representation(self, instance):
class Meta:
model = IdentificationTask
fields = (
"type",
"action",
"created_at"
)
extra_kwargs = {
Expand All @@ -1172,7 +1172,7 @@ class IdentificationTaskResultSerializer(serializers.ModelSerializer):
confidence = serializers.FloatField(min_value=0, max_value=1, read_only=True)
confidence_label = serializers.SerializerMethodField()
is_high_confidence = serializers.SerializerMethodField()
source = serializers.ChoiceField(source='result_source',choices=IdentificationTask.ResultSource.choices)
source = serializers.ChoiceField(source='result_source',read_only=True, choices=IdentificationTask.ResultSource.choices)

def get_confidence_label(self, obj) -> str:
return obj.confidence_label
Expand Down Expand Up @@ -1213,7 +1213,8 @@ class Meta(BaseAssignmentSerializer.Meta):
fields = ("user", "annotation_id",) + BaseAssignmentSerializer.Meta.fields

observation = SimplifiedObservationWithPhotosSerializer(source='report', read_only=True)
public_photo = SimplePhotoSerializer(source='photo', required=True)
public_photo_uuid = serializers.UUIDField(source='photo__uuid', write_only=True)
public_photo = SimplePhotoSerializer(source='photo', read_only=True)
review = IdentificationTaskReviewSerializer(source='*', allow_null=True, read_only=True)
result = IdentificationTaskResultSerializer(source='*', read_only=True, allow_null=True)
assignments = UserAssignmentSerializer(many=True, read_only=True)
Expand All @@ -1222,6 +1223,7 @@ class Meta:
model = IdentificationTask
fields = (
'observation',
'public_photo_uuid',
'public_photo',
'assignments',
'status',
Expand All @@ -1235,13 +1237,104 @@ class Meta:
'updated_at'
)
extra_kwargs = {
'status': {'default': IdentificationTask.Status.OPEN},
'status': {'default': IdentificationTask.Status.OPEN, 'read_only': True},
'public_note': {'allow_null': True, 'allow_blank': True},
'num_annotations': {'source': 'total_finished_annotations','min_value': 0},
'created_at': {'read_only': True},
'updated_at': {'read_only': True},
}

class CreateReviewSerializer(serializers.ModelSerializer):
action = serializers.HiddenField(default=None)

def validate(self, data):
del data['review_type']
data['validation_complete'] = True
data['simplified_annotation'] = True
data['report'] = self.context.get('report')

return data

def create(self, validated_data):
report = validated_data.pop('report')

# TODO: Create a Review model for this.
obj, _ = ExpertReportAnnotation.objects.update_or_create(
user=self.context.get('request').user,
report=report,
defaults=validated_data
)
obj.create_replicas()

identification_task = report.identification_task
identification_task.refresh_from_db()
return identification_task

class Meta:
model = IdentificationTask
fields = (
'action',
)
extra_kwargs = {
'action': {'source': 'review_type','read_only': False},
}

class CreateAgreeReviewSerializer(CreateReviewSerializer):
action = serializers.ChoiceField(source='review_type', choices=[IdentificationTask.Review.AGREE.value], default=IdentificationTask.Review.AGREE.value)

def validate(self, data):
data = super().validate(data)

data['revise'] = False
data['status'] = ExpertReportAnnotation.STATUS_HIDDEN if not data['report'].identification_task.is_safe else ExpertReportAnnotation.STATUS_PUBLIC

return data

class Meta(CreateReviewSerializer.Meta):
fields = (
'action',
)

class CreateOverwriteReviewSerializer(CreateReviewSerializer):
action = serializers.ChoiceField(source='review_type', choices=[IdentificationTask.Review.OVERWRITE.value], default=IdentificationTask.Review.OVERWRITE.value)

public_photo_uuid = serializers.UUIDField(source='photo__uuid', write_only=True)
result = AnnotationSerializer.AnnotationClassificationSerializer(source='*', required=True, allow_null=True)

def validate(self, data):
data = super().validate(data)

# Case Not an insect will be empty taxon. In case of update we need to for it to None
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar error in comment: 'we need to for it to None' should be 'we need to force it to None'.

Suggested change
# Case Not an insect will be empty taxon. In case of update we need to for it to None
# Case Not an insect will be empty taxon. In case of update we need to force it to None

Copilot uses AI. Check for mistakes.
data['taxon'] = data.pop('taxon', None)
data['validation_value'] = data.pop('validation_value', None)
data['confidence'] = data.pop('confidence', 0)

data['revise'] = True
data['status'] = ExpertReportAnnotation.STATUS_HIDDEN if not data.pop('is_safe') or data['taxon'] is None else ExpertReportAnnotation.STATUS_PUBLIC
data['simplified_annotation'] = False
data['edited_user_notes'] = data.pop('public_note', None) or ""

if public_photo_uuid := data.pop('photo__uuid', None):
try:
data['best_photo'] = Photo.objects.get(report=data['report'], uuid=public_photo_uuid)
except Photo.DoesNotExist:
raise serializers.ValidationError("The photo does not exist or does not belong to the observation.")

return data

class Meta(CreateReviewSerializer.Meta):
fields = (
'action',
'public_photo_uuid',
'is_safe',
'public_note',
'result',
)
extra_kwargs = {
'is_safe': {'read_only': False},
'public_note': {'allow_null': True, 'allow_blank': False, 'read_only': False}
}

class ObservationSerializer(BaseReportWithPhotosSerializer):
class IdentificationSerializer(serializers.ModelSerializer):
photo = SimplePhotoSerializer(required=True)
Expand Down
15 changes: 15 additions & 0 deletions api/tests/integration/identification_tasks/review/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import pytest

from api.tests.utils import grant_permission_to_user

from tigacrafting.models import IdentificationTask

# NOTE: needed for token with perms fixture
@pytest.fixture
def model_class():
return IdentificationTask

@pytest.fixture
def endpoint(identification_task):
return f"identification-tasks/{identification_task.report.pk}/review"

100 changes: 100 additions & 0 deletions api/tests/integration/identification_tasks/review/create.tavern.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---

test_name: New identification tasks review can not be created by not regular users with permissions.

includes:
- !include schema.yml

marks:
- usefixtures:
- api_live_url
- endpoint
- app_user_token
- jwt_token_user

stages:
- name: Review is not allowed for mobile users
request:
url: "{api_live_url}/{endpoint}/"
headers:
Authorization: "Bearer {app_user_token}"
method: "POST"
response:
status_code: 403
- name: Non auth user can not review new identification tasks.
request:
url: "{api_live_url}/{endpoint}/"
method: "POST"
response:
status_code: 401
- name: User without perm can not review new identification tasks.
request:
url: "{api_live_url}/{endpoint}/"
method: "POST"
headers:
Authorization: "Bearer {jwt_token_user:s}"
response:
status_code: 403

---

test_name: Identification tasks reviews (agree) can be made only by authenticated users with permissions.

includes:
- !include schema.yml

marks:
- usefixtures:
- api_live_url
- endpoint
- user_with_role_reviewer
- jwt_token_user

stages:
- name: User with perm can review identification tasks.
request:
url: "{api_live_url}/{endpoint}/"
method: "POST"
headers:
Authorization: "Bearer {jwt_token_user:s}"
json:
action: 'agree'
response:
status_code: 201
json:
action: 'agree'
created_at: !re_fullmatch \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}Z


---

test_name: Identification tasks reviews (overwrite) can be made only by authenticated users with permissions.

includes:
- !include schema.yml

marks:
- usefixtures:
- api_live_url
- endpoint
- user_with_role_reviewer
- jwt_token_user

stages:
- name: User with perm can review identification tasks.
request:
url: "{api_live_url}/{endpoint}/"
method: "POST"
headers:
Authorization: "Bearer {jwt_token_user:s}"
json:
action: 'overwrite'
public_photo_uuid: "{identification_task.photo.uuid}"
is_safe: True
public_note: "Test new public note"
result: null
response:
status_code: 201
json:
action: 'overwrite'
created_at: !re_fullmatch \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}Z
16 changes: 16 additions & 0 deletions api/tests/integration/identification_tasks/review/schema.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---

name: Common test information
description: Login information for test server

variables:
response_data_validation: &retrieve_validation
action: !anystr
created_at: !re_fullmatch \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}Z
response_list_data_validation: &response_list_validation
count: !anyint
next: !anything
previous: !anything
results: [
<<: *retrieve_validation
]
Loading