diff --git a/backend/annotation/admin.py b/backend/annotation/admin.py index 8c38f3f..cd872d8 100644 --- a/backend/annotation/admin.py +++ b/backend/annotation/admin.py @@ -1,3 +1,10 @@ from django.contrib import admin +from annotation.models import Label + + # Register your models here. +@admin.register(Label) +class LabelAdmin(admin.ModelAdmin): + list_display = ("text", "description") + search_fields = ("text",) diff --git a/backend/annotation/fixtures/initial.json b/backend/annotation/fixtures/initial.json new file mode 100644 index 0000000..30f866e --- /dev/null +++ b/backend/annotation/fixtures/initial.json @@ -0,0 +1,50 @@ +[ + { + "model": "annotation.label", + "pk": 1, + "fields": { + "text": "Important", + "description": "This problem is particularly important." + } + }, + { + "model": "annotation.label", + "pk": 2, + "fields": { + "text": "Review needed", + "description": "This problem needs to be reviewed by a master annotator." + } + }, + { + "model": "annotation.label", + "pk": 3, + "fields": { + "text": "Ambiguous", + "description": "The problem statement is ambiguous and needs clarification." + } + }, + { + "model": "annotation.label", + "pk": 4, + "fields": { + "text": "Incomplete knowledge", + "description": "Some knowledge base items are missing." + } + }, + { + "model": "annotation.label", + "pk": 5, + "fields": { + "text": "Spelling", + "description": "The premises or hypothesis are misspelled, which may lead to wrong analyses." + } + }, + { + "model": "annotation.label", + "pk": 6, + "fields": { + "text": "Wrong entailment label", + "description": "The entailment label is incorrect." + } + } +] diff --git a/backend/annotation/migrations/0002_label_labeling.py b/backend/annotation/migrations/0002_label_labeling.py new file mode 100644 index 0000000..78dcb38 --- /dev/null +++ b/backend/annotation/migrations/0002_label_labeling.py @@ -0,0 +1,123 @@ +# Generated by Django 4.2.27 on 2025-12-11 15:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("problem", "0007_alter_problem_options"), + ("annotation", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Label", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.CharField(max_length=255)), + ( + "description", + models.TextField( + help_text="A detailed description of the label, indicating when it should be used." + ), + ), + ], + options={ + "ordering": ["text"], + }, + ), + migrations.CreateModel( + name="Labeling", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("attached_at", models.DateTimeField(auto_now_add=True)), + ( + "removed_at", + models.DateTimeField( + blank=True, + help_text="When this label was removed from the problem.", + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + help_text="Optional notes explaining why this label was added or removed.", + ), + ), + ( + "attached_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="labelings_attached", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="labelings", + to="annotation.label", + ), + ), + ( + "problem", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="labelings", + to="problem.problem", + ), + ), + ( + "removed_by", + models.ForeignKey( + blank=True, + help_text="User who removed this label.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="labelings_removed", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-attached_at"], + "permissions": [ + ("delete_own_labeling", "Can remove own labeling from problems"), + ("delete_any_labeling", "Can remove any labeling from problems"), + ], + "indexes": [ + models.Index( + fields=["problem", "removed_at"], + name="annotation__problem_e7ac7b_idx", + ), + models.Index( + fields=["label", "removed_at"], + name="annotation__label_i_a229d2_idx", + ), + ], + }, + ), + ] diff --git a/backend/annotation/models.py b/backend/annotation/models.py index e0786a2..0286386 100644 --- a/backend/annotation/models.py +++ b/backend/annotation/models.py @@ -81,3 +81,83 @@ class KnowledgeBaseAnnotation(models.Model): ) created_at = models.DateTimeField(auto_now_add=True) + + +class Label(models.Model): + text = models.CharField(max_length=255) + description = models.TextField( + help_text="A detailed description of the label, indicating when it should be used." + ) + + class Meta: + ordering = ["text"] + + def __str__(self): + return self.text + + +class Labeling(models.Model): + """ + The attachment of a label to a problem. + + Each time a label is attached to a problem, a new Labeling record is created. + When removed, the record is marked as removed (not deleted), so the history of labelings is preserved. + """ + + problem = models.ForeignKey( + Problem, + on_delete=models.CASCADE, + related_name="labelings", + ) + + label = models.ForeignKey( + Label, + on_delete=models.CASCADE, + related_name="labelings", + ) + + attached_at = models.DateTimeField(auto_now_add=True) + attached_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="labelings_attached", + ) + + removed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When this label was removed from the problem.", + ) + removed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="labelings_removed", + null=True, + blank=True, + help_text="User who removed this label.", + ) + + notes = models.TextField( + blank=True, + help_text="Optional notes explaining why this label was added or removed.", + ) + + class Meta: + ordering = ["-attached_at"] + indexes = [ + models.Index(fields=["problem", "removed_at"]), + models.Index(fields=["label", "removed_at"]), + ] + permissions = [ + ("delete_own_labeling", "Can remove own labeling from problems"), + ("delete_any_labeling", "Can remove any labeling from problems"), + ] + + + def is_active(self) -> bool: + """Check if this labeling is currently active (not removed).""" + return self.removed_at is None + + def __str__(self): + status = "active" if self.is_active() else f"removed at {self.removed_at}" + return f"Label '{self.label.text}' on Problem {self.problem.pk} ({status})" diff --git a/backend/annotation/serializers.py b/backend/annotation/serializers.py new file mode 100644 index 0000000..02f8f93 --- /dev/null +++ b/backend/annotation/serializers.py @@ -0,0 +1,121 @@ +from rest_framework import serializers + +from django.contrib.auth.models import AnonymousUser + +from annotation.models import Label, Labeling +from problem.models import Problem +from user.models import User + + +class LabelSerializer(serializers.ModelSerializer): + """ + Serializer for Label model. + """ + + class Meta: + model = Label + fields = ["id", "text", "description"] + + +class ActiveLabelSerializer(serializers.Serializer): + """ + Serializer for active labels attached to a problem. + Includes attachedInfo and removable status based on current user. + """ + + id = serializers.IntegerField(source="label.id") + text = serializers.CharField(source="label.text") + description = serializers.CharField(source="label.description") + attachedInfo = serializers.SerializerMethodField() + removable = serializers.SerializerMethodField() + + def get_attachedInfo(self, labeling: Labeling) -> dict: + """Get attachment information for the label.""" + request = self.context.get("request") + user: User | AnonymousUser | None = request.user if request else None + + if user and user.is_anonymous is False: + attached_by_current_user = labeling.attached_by.pk == user.pk + else: + attached_by_current_user = False + + return { + "userName": labeling.attached_by.username, + "date": labeling.attached_at.isoformat(), + "attachedByCurrentUser": attached_by_current_user, + } + + def get_removable(self, labeling: Labeling) -> bool: + """Determine if the label is removable by the current user.""" + request = self.context.get("request") + user: User | AnonymousUser | None = request.user if request else None + + if user is None or user.is_anonymous: + return False + + if user.is_superuser or user.has_perm("annotation.delete_any_labeling"): + return True + + if user.has_perm("annotation.delete_own_labeling"): + return labeling.attached_by.pk == user.pk + + return False + + +class LabelingSerializer(serializers.ModelSerializer): + """ + Serializer for Labeling model, including the full label details. + """ + + label = LabelSerializer(read_only=True) + attachedAt = serializers.DateTimeField(source="attached_at") + attachedBy = serializers.PrimaryKeyRelatedField( + source="attached_by", read_only=True + ) + removedAt = serializers.DateTimeField(source="removed_at", allow_null=True) + removedBy = serializers.PrimaryKeyRelatedField( + source="removed_by", allow_null=True, read_only=True + ) + + class Meta: + model = Labeling + fields = [ + "id", + "label", + "attached_at", + "attached_by", + "removed_at", + "removed_by", + "notes", + ] + + +class SelectedLabelSerializer(serializers.Serializer): + """Serializer for a selected label in the save labels input.""" + + id = serializers.IntegerField() + + def validate_id(self, value): + """Validate that the label exists.""" + if not Label.objects.filter(id=value).exists(): + raise serializers.ValidationError(f"Label with ID {value} does not exist.") + return value + + +class SaveLabelsInputSerializer(serializers.Serializer): + """ + Serializer for validating save labels input data. + Used when saving/updating labels for a problem. + """ + + problemId = serializers.IntegerField() + selectedLabels = SelectedLabelSerializer(many=True, allow_empty=True) + remarks = serializers.CharField(required=False, allow_blank=True, default="") + + def validate_problemId(self, value): + """Validate that the problem exists.""" + if not Problem.objects.filter(id=value).exists(): + raise serializers.ValidationError( + f"Problem with ID {value} does not exist." + ) + return value diff --git a/backend/annotation/views.py b/backend/annotation/views.py index 91ea44a..aefa18e 100644 --- a/backend/annotation/views.py +++ b/backend/annotation/views.py @@ -1,3 +1,138 @@ -from django.shortcuts import render - -# Create your views here. +from django.db import transaction +from django.utils import timezone +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import ( + IsAuthenticated, + AllowAny, + SAFE_METHODS, +) + +from annotation.models import Label, Labeling +from annotation.serializers import ( + LabelSerializer, + SaveLabelsInputSerializer, +) +from problem.models import Problem +from user.models import User +from langpro_annotator.logger import logger + + +class SaveLabelingsPermission(IsAuthenticated): + """Permission class for saving labelings.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + + if request.user.is_superuser: + return True + + return request.user.has_perm("annotation.add_labeling") + + +class LabelView(ModelViewSet): + """ + ViewSet for Label model. + + GET: All users can list and retrieve labels. + POST: Only selected users can save labelings (attach/remove labels from problems). + """ + + queryset = Label.objects.all().order_by("text") + serializer_class = LabelSerializer + http_method_names = ["get", "post", "head", "options"] + + def get_permissions(self): + if self.request.method in SAFE_METHODS: + return [AllowAny()] + return [SaveLabelingsPermission()] + + def create(self, request: Request) -> Response: + """ + Save labelings for a problem (attach/remove labels). + + Expects a payload with: + - problemId: ID of the problem + - selectedLabels: List of labels to be attached (with at least 'id' field) + - remarks: Optional notes + """ + serializer = SaveLabelsInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data: dict = serializer.validated_data # type: ignore + + problem_id = validated_data["problemId"] + selected_labels = validated_data["selectedLabels"] + remarks = validated_data.get("remarks", "") + + problem = Problem.objects.get(id=problem_id) + user: User = request.user # type: ignore + + selected_label_ids = {label["id"] for label in selected_labels} + + self._update_labelings(problem, user, selected_label_ids, remarks) + + return Response({"ok": True}) + + def _update_labelings( + self, + problem: Problem, + user: User, + selected_label_ids: set[int], + remarks: str, + ) -> None: + """Update labelings for a problem based on selected labels.""" + + with transaction.atomic(): + active_labelings = Labeling.objects.filter( + problem=problem, removed_at__isnull=True + ).select_related("label", "attached_by") + + current_label_ids = {labeling.label.pk for labeling in active_labelings} + labels_to_remove = current_label_ids - selected_label_ids + labels_to_add = selected_label_ids - current_label_ids + + for labeling in active_labelings: + if labeling.label.pk in labels_to_remove: + self._remove_labeling( + labeling=labeling, + user=user, + remarks=remarks, + ) + + for label_id in labels_to_add: + self._create_labeling( + label_id=label_id, + problem=problem, + user=user, + remarks=remarks, + ) + + def _create_labeling( + self, label_id: int, problem: Problem, user: User, remarks: str + ) -> None: + """Create a new labeling.""" + + Labeling.objects.create( + problem=problem, + label_id=label_id, + attached_by=user, + notes=remarks, + ) + + def _remove_labeling(self, labeling: Labeling, user: User, remarks: str) -> None: + """Mark a labeling as removed.""" + + if not user.can_remove_labeling(labeling): + logger.warning( + f"User {user.username} attempted to remove label {labeling.label.pk} " + f"attached by {labeling.attached_by.username}" + ) + raise PermissionDenied("You can only remove labels you attached yourself.") + labeling.removed_at = timezone.now() + labeling.removed_by = user + if remarks: + labeling.notes = remarks + labeling.save() diff --git a/backend/annotation/views_test.py b/backend/annotation/views_test.py new file mode 100644 index 0000000..4daeea8 --- /dev/null +++ b/backend/annotation/views_test.py @@ -0,0 +1,290 @@ +import pytest +from rest_framework import status + +from annotation.models import Label, Labeling + + +@pytest.fixture +def sample_label(db): + """Creates a sample label for testing.""" + return Label.objects.create( + text="Test Label", + description="A test label for testing purposes.", + ) + + +@pytest.fixture +def another_label(db): + """Creates another sample label for testing.""" + return Label.objects.create( + text="Another Label", + description="Another test label.", + ) + + +@pytest.fixture +def labeling_by_annotator(db, sample_problem, sample_label, annotator): + """Creates a labeling attached by an annotator.""" + return Labeling.objects.create( + problem=sample_problem, + label=sample_label, + attached_by=annotator, + ) + + +@pytest.fixture +def labeling_by_master(db, sample_problem, another_label, master_annotator): + """Creates a labeling attached by a master annotator.""" + return Labeling.objects.create( + problem=sample_problem, + label=another_label, + attached_by=master_annotator, + ) + + +class TestLabelViewGetPermissions: + """Tests for GET permissions on the Label endpoint.""" + + def test_unauthenticated_user_can_list_labels(self, api_client, sample_label): + """Unauthenticated users should be able to list labels.""" + response = api_client.get("/api/label/") + assert response.status_code == status.HTTP_200_OK + + def test_visitor_can_list_labels(self, api_client, visitor, sample_label): + """Visitors should be able to list labels.""" + api_client.force_authenticate(user=visitor) + response = api_client.get("/api/label/") + assert response.status_code == status.HTTP_200_OK + + def test_annotator_can_list_labels(self, api_client, annotator, sample_label): + """Annotators should be able to list labels.""" + api_client.force_authenticate(user=annotator) + response = api_client.get("/api/label/") + assert response.status_code == status.HTTP_200_OK + + def test_master_annotator_can_list_labels( + self, api_client, master_annotator, sample_label + ): + """Master annotators should be able to list labels.""" + api_client.force_authenticate(user=master_annotator) + response = api_client.get("/api/label/") + assert response.status_code == status.HTTP_200_OK + + def test_unauthenticated_user_can_retrieve_label(self, api_client, sample_label): + """Unauthenticated users should be able to retrieve a single label.""" + response = api_client.get(f"/api/label/{sample_label.id}/") + assert response.status_code == status.HTTP_200_OK + + def test_visitor_can_retrieve_label(self, api_client, visitor, sample_label): + """Visitors should be able to retrieve a single label.""" + api_client.force_authenticate(user=visitor) + response = api_client.get(f"/api/label/{sample_label.id}/") + assert response.status_code == status.HTTP_200_OK + + +class TestSaveLabelingsPermissions: + """Tests for POST permissions (saving labelings) on the Label endpoint.""" + + def test_unauthenticated_user_cannot_save_labelings( + self, api_client, sample_problem, sample_label + ): + """Unauthenticated users should not be able to save labelings.""" + data = { + "problemId": sample_problem.id, + "selectedLabels": [{"id": sample_label.id}], + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_visitor_cannot_save_labelings( + self, api_client, visitor, sample_problem, sample_label + ): + """Visitors should not be able to save labelings.""" + api_client.force_authenticate(user=visitor) + data = { + "problemId": sample_problem.id, + "selectedLabels": [{"id": sample_label.id}], + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_annotator_can_save_labelings( + self, api_client, annotator, sample_problem, sample_label + ): + """Annotators should be able to save labelings.""" + api_client.force_authenticate(user=annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [{"id": sample_label.id}], + "remarks": "Test remark", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.data["ok"] is True + + # Verify labeling was created + labeling = Labeling.objects.get(problem=sample_problem, label=sample_label) + assert labeling.attached_by == annotator + assert labeling.notes == "Test remark" + + def test_master_annotator_can_save_labelings( + self, api_client, master_annotator, sample_problem, sample_label + ): + """Master annotators should be able to save labelings.""" + api_client.force_authenticate(user=master_annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [{"id": sample_label.id}], + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.data["ok"] is True + + +class TestRemoveLabelingPermissions: + """Tests for labeling removal permissions.""" + + def test_annotator_can_remove_own_labeling( + self, api_client, annotator, sample_problem, labeling_by_annotator + ): + """Annotators should be able to remove labels they attached.""" + api_client.force_authenticate(user=annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [], # Empty = remove the existing label + "remarks": "Removing my own label", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + + # Verify labeling was marked as removed + labeling_by_annotator.refresh_from_db() + assert labeling_by_annotator.removed_at is not None + assert labeling_by_annotator.removed_by == annotator + + def test_annotator_cannot_remove_others_labeling( + self, api_client, annotator, sample_problem, labeling_by_master + ): + """Annotators should not be able to remove labels attached by others.""" + api_client.force_authenticate(user=annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [], # Empty = try to remove the existing label + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + assert ( + "You can only remove labels you attached yourself" in response.data["detail"] + ) + + # Verify labeling was NOT removed + labeling_by_master.refresh_from_db() + assert labeling_by_master.removed_at is None + + def test_master_annotator_can_remove_own_labeling( + self, api_client, master_annotator, sample_problem, labeling_by_master + ): + """Master annotators should be able to remove labels they attached.""" + api_client.force_authenticate(user=master_annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [], + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + + labeling_by_master.refresh_from_db() + assert labeling_by_master.removed_at is not None + + def test_master_annotator_can_remove_others_labeling( + self, api_client, master_annotator, sample_problem, labeling_by_annotator + ): + """Master annotators should be able to remove labels attached by others.""" + api_client.force_authenticate(user=master_annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [], + "remarks": "Removing annotator's label", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + + labeling_by_annotator.refresh_from_db() + assert labeling_by_annotator.removed_at is not None + assert labeling_by_annotator.removed_by == master_annotator + + +class TestLabelingAddAndRemove: + """Tests for adding / removing labelings.""" + + def test_adding_label_creates_labeling( + self, api_client, annotator, sample_problem, sample_label + ): + """Adding a label should create a new Labeling record.""" + api_client.force_authenticate(user=annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [{"id": sample_label.id}], + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + + assert Labeling.objects.filter( + problem=sample_problem, label=sample_label, removed_at__isnull=True + ).exists() + + def test_adding_multiple_labels( + self, api_client, annotator, sample_problem, sample_label, another_label + ): + """Should be able to add multiple labels at once.""" + api_client.force_authenticate(user=annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [{"id": sample_label.id}, {"id": another_label.id}], + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + + active_labelings = Labeling.objects.filter( + problem=sample_problem, removed_at__isnull=True + ) + assert active_labelings.count() == 2 + + def test_keeping_existing_labels_unchanged( + self, + api_client, + annotator, + sample_problem, + labeling_by_annotator, + another_label, + ): + """Labels already attached should remain if still in selectedLabels.""" + original_labeling_id = labeling_by_annotator.id + api_client.force_authenticate(user=annotator) + data = { + "problemId": sample_problem.id, + "selectedLabels": [ + {"id": labeling_by_annotator.label.id}, + {"id": another_label.id}, + ], + "remarks": "", + } + response = api_client.post("/api/label/", data, format="json") + assert response.status_code == status.HTTP_200_OK + + # Original labeling should still be active + labeling_by_annotator.refresh_from_db() + assert labeling_by_annotator.id == original_labeling_id + assert labeling_by_annotator.removed_at is None + + # New labeling should be created + assert Labeling.objects.filter( + problem=sample_problem, label=another_label, removed_at__isnull=True + ).exists() diff --git a/backend/conftest.py b/backend/conftest.py index 581ce99..94f7c5b 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -2,8 +2,12 @@ import pytest from typing import Generator from django.test import Client as APIClient +from django.contrib.auth.models import Group, Permission +from rest_framework.test import APIClient as DRFAPIClient -from user.models import User +from user.models import User, GroupName +from user.permissions import ANNOTATOR_PERMISSIONS, MASTER_ANNOTATOR_PERMISSIONS +from problem.models import Problem, Sentence @pytest.fixture() @@ -37,3 +41,80 @@ def user_client(client, user) -> Generator[APIClient, None, None]: client.force_login(user) yield client client.logout() + + +@pytest.fixture +def api_client(): + """Returns a DRF APIClient instance.""" + return DRFAPIClient() + + +@pytest.fixture +def visitor(db): + """Creates a visitor user (no special permissions).""" + return User.objects.create_user( + username="visitor", + email="visitor@test.com", + password="testpassword", + ) + + +@pytest.fixture +def annotator(db): + """Creates an annotator user with annotator permissions.""" + user = User.objects.create_user( + username="annotator", + email="annotator@test.com", + password="testpassword", + ) + group, _ = Group.objects.get_or_create(name=GroupName.ANNOTATORS) + + for app_label, codename in ANNOTATOR_PERMISSIONS: + try: + perm = Permission.objects.get( + content_type__app_label=app_label, + codename=codename, + ) + group.permissions.add(perm) + except Permission.DoesNotExist: + pass + user.groups.add(group) + return user + + +@pytest.fixture +def master_annotator(db): + """Creates a master annotator user with master annotator permissions.""" + user = User.objects.create_user( + username="master_annotator", + email="master@test.com", + password="testpassword", + ) + group, _ = Group.objects.get_or_create(name=GroupName.MASTER_ANNOTATORS) + + for app_label, codename in MASTER_ANNOTATOR_PERMISSIONS: + try: + perm = Permission.objects.get( + content_type__app_label=app_label, + codename=codename, + ) + group.permissions.add(perm) + except Permission.DoesNotExist: + pass + user.groups.add(group) + return user + + +@pytest.fixture +def sample_problem(db): + """Creates a sample problem for testing.""" + hypothesis = Sentence.objects.create(text="This is a hypothesis.") + premise = Sentence.objects.create(text="This is a premise.") + problem = Problem.objects.create( + dataset=Problem.Dataset.USER, + hypothesis=hypothesis, + entailment_label=Problem.EntailmentLabel.NEUTRAL, + extra_data={}, + ) + problem.premises.add(premise) + return problem diff --git a/backend/langpro_annotator/urls.py b/backend/langpro_annotator/urls.py index f1fd866..97e77ca 100644 --- a/backend/langpro_annotator/urls.py +++ b/backend/langpro_annotator/urls.py @@ -21,6 +21,7 @@ from rest_framework import routers +from annotation.views import LabelView from problem.views.problem import ProblemView from .index import index @@ -29,6 +30,7 @@ api_router = routers.DefaultRouter() # register viewsets with this router api_router.register(r"problem", ProblemView, basename="problem") +api_router.register(r"label", LabelView, basename="labels") if settings.PROXY_FRONTEND: diff --git a/backend/problem/models.py b/backend/problem/models.py index de54e51..22b9a7b 100644 --- a/backend/problem/models.py +++ b/backend/problem/models.py @@ -1,7 +1,6 @@ from django.db import models from django.db.models import QuerySet -from problem.services import FracasData, SNLIData, SickData from langpro_annotator.logger import logger @@ -99,11 +98,3 @@ class Relationship(models.TextChoices): on_delete=models.CASCADE, related_name="knowledge_bases", ) - - def serialize(self) -> dict: - return { - "id": self.pk, - "entity1": self.entity1, - "entity2": self.entity2, - "relationship": self.relationship, - } diff --git a/backend/problem/serializers.py b/backend/problem/serializers.py index 4d89f59..3f1ce4c 100644 --- a/backend/problem/serializers.py +++ b/backend/problem/serializers.py @@ -1,4 +1,9 @@ from rest_framework import serializers +from django.contrib.auth.models import AnonymousUser + +from annotation.serializers import ActiveLabelSerializer +from user.models import User +from annotation.models import Label, Labeling from problem.services import FracasData, SNLIData, SickData from problem.models import Problem, KnowledgeBase, Sentence @@ -51,6 +56,7 @@ class ProblemSerializer(serializers.ModelSerializer): entailmentLabel = serializers.CharField(source="entailment_label") extraData = serializers.SerializerMethodField() kbItems = serializers.SerializerMethodField() + labels = serializers.SerializerMethodField() class Meta: model = Problem @@ -63,6 +69,7 @@ class Meta: "extraData", "kbItems", "base", + "labels", ] def get_premises(self, problem): @@ -90,6 +97,13 @@ def get_kbItems(self, problem): kb_items = problem.knowledge_bases.all() return KnowledgeBaseSerializer(kb_items, many=True).data + def get_labels(self, problem): + """Get active labels with attachment info and removability.""" + active_labelings = problem.labelings.filter(removed_at__isnull=True) + return ActiveLabelSerializer( + active_labelings, many=True, context=self.context + ).data + def create(self, validated_data: dict) -> Problem: """ Create a new Problem instance from validated input data. @@ -145,7 +159,7 @@ def update(self, instance: Problem, validated_data: dict) -> Problem: raise serializers.ValidationError( f"Base problem with ID {validated_base_id} does not exist." ) - instance.base = base_problem # type: ignore + instance.base = base_problem # type: ignore instance.save() diff --git a/backend/problem/views/problem.py b/backend/problem/views/problem.py index 8ad685d..3b37b3d 100644 --- a/backend/problem/views/problem.py +++ b/backend/problem/views/problem.py @@ -26,7 +26,10 @@ def has_permission(self, request, view): class ProblemView(ModelViewSet): - queryset = Problem.objects.all() + queryset = Problem.objects.prefetch_related( + "labelings__label", + "labelings__attached_by", + ) serializer_class = ProblemSerializer def get_permissions(self): diff --git a/backend/problem/views/problem_test.py b/backend/problem/views/problem_test.py index 1f53727..09ab45c 100644 --- a/backend/problem/views/problem_test.py +++ b/backend/problem/views/problem_test.py @@ -1,82 +1,6 @@ import pytest -from django.contrib.auth.models import Group, Permission from rest_framework import status -from problem.models import Problem, Sentence -from user.models import User, GroupName -from user.permissions import ANNOTATOR_PERMISSIONS, MASTER_ANNOTATOR_PERMISSIONS - - -@pytest.fixture -def visitor(db): - """Creates a visitor user (no special permissions).""" - return User.objects.create_user( - username="visitor", - email="visitor@test.com", - password="testpassword", - ) - - -@pytest.fixture -def annotator(db): - """Creates an annotator user with annotator permissions.""" - user = User.objects.create_user( - username="annotator", - email="annotator@test.com", - password="testpassword", - ) - group, _ = Group.objects.get_or_create(name=GroupName.ANNOTATORS) - - for app_label, codename in ANNOTATOR_PERMISSIONS: - try: - perm = Permission.objects.get( - content_type__app_label=app_label, - codename=codename, - ) - group.permissions.add(perm) - except Permission.DoesNotExist: - pass - user.groups.add(group) - return user - - -@pytest.fixture -def master_annotator(db): - """Creates a master annotator user with master annotator permissions.""" - user = User.objects.create_user( - username="master_annotator", - email="master@test.com", - password="testpassword", - ) - group, _ = Group.objects.get_or_create(name=GroupName.MASTER_ANNOTATORS) - - for app_label, codename in MASTER_ANNOTATOR_PERMISSIONS: - try: - perm = Permission.objects.get( - content_type__app_label=app_label, - codename=codename, - ) - group.permissions.add(perm) - except Permission.DoesNotExist: - pass - user.groups.add(group) - return user - - -@pytest.fixture -def sample_problem(db): - """Creates a sample problem for testing.""" - hypothesis = Sentence.objects.create(text="This is a hypothesis.") - premise = Sentence.objects.create(text="This is a premise.") - problem = Problem.objects.create( - dataset=Problem.Dataset.USER, - hypothesis=hypothesis, - entailment_label=Problem.EntailmentLabel.NEUTRAL, - extra_data={}, - ) - problem.premises.add(premise) - return problem - @pytest.fixture def problem_input_data(): diff --git a/backend/user/models.py b/backend/user/models.py index 6add7f9..d9a4627 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -1,6 +1,8 @@ from enum import StrEnum import django.contrib.auth.models as django_auth_models +from annotation.models import Labeling + class GroupName(StrEnum): MASTER_ANNOTATORS = "Master Annotators" @@ -55,3 +57,15 @@ def can_create_problem(self) -> bool: Determines whether the user can create new problems. """ return self.has_perm("problem.add_problem") + + def can_remove_labeling(self, labeling: Labeling) -> bool: + """ + Determines whether the user can remove a specific labeling. + """ + if self.is_superuser or self.has_perm("annotation.delete_any_labeling"): + return True + + if self.has_perm("annotation.delete_own_labeling"): + return labeling.attached_by.pk == self.pk + + return False diff --git a/backend/user/permissions.py b/backend/user/permissions.py index 96f29c6..0b6e93c 100644 --- a/backend/user/permissions.py +++ b/backend/user/permissions.py @@ -5,6 +5,8 @@ ("problem", "change_knowledgebase"), ("problem", "delete_knowledgebase"), ("problem", "view_knowledgebase"), + ("annotation", "add_labeling"), + ("annotation", "delete_own_labeling"), ] MASTER_ANNOTATOR_PERMISSIONS = ANNOTATOR_PERMISSIONS + [ @@ -16,4 +18,8 @@ ("problem", "change_problem"), ("problem", "delete_problem"), ("problem", "view_problem"), + ("annotation", "add_label"), + ("annotation", "change_label"), + ("annotation", "delete_label"), + ("annotation", "delete_any_labeling"), ] diff --git a/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts b/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts index 38ffd2c..7ca60aa 100644 --- a/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts +++ b/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts @@ -67,6 +67,7 @@ describe("AnnotationInputComponent", () => { relationship: KnowledgeBaseRelationship.EQUAL } ], + labels: [], dataset: Dataset.USER, extraData: null }; @@ -110,6 +111,7 @@ describe("AnnotationInputComponent", () => { hypothesis: "Empty test hypothesis", entailmentLabel: EntailmentLabel.NEUTRAL, kbItems: [], + labels: [], dataset: Dataset.USER, extraData: null }; @@ -134,6 +136,7 @@ describe("AnnotationInputComponent", () => { hypothesis: "Test hypothesis", entailmentLabel: EntailmentLabel.CONTRADICTION, kbItems: [], + labels: [], dataset: Dataset.USER, extraData: null }; @@ -156,6 +159,7 @@ describe("AnnotationInputComponent", () => { hypothesis: "", entailmentLabel: EntailmentLabel.UNKNOWN, kbItems: [], + labels: [], dataset: Dataset.USER, extraData: null }; @@ -176,6 +180,7 @@ describe("AnnotationInputComponent", () => { hypothesis: "", entailmentLabel: EntailmentLabel.UNKNOWN, kbItems: [], + labels: [], dataset: Dataset.USER, extraData: null }; diff --git a/frontend/src/app/annotate/annotation-input/problem-details/entailment-label-badge/entailment-label-badge.component.test.ts b/frontend/src/app/annotate/annotation-input/problem-details/entailment-label-badge/entailment-label-badge.component.spec.ts similarity index 91% rename from frontend/src/app/annotate/annotation-input/problem-details/entailment-label-badge/entailment-label-badge.component.test.ts rename to frontend/src/app/annotate/annotation-input/problem-details/entailment-label-badge/entailment-label-badge.component.spec.ts index f2a3028..08fd1ca 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/entailment-label-badge/entailment-label-badge.component.test.ts +++ b/frontend/src/app/annotate/annotation-input/problem-details/entailment-label-badge/entailment-label-badge.component.spec.ts @@ -15,7 +15,7 @@ describe("EntailmentLabelBadgeComponent", () => { fixture = TestBed.createComponent(EntailmentLabelBadgeComponent); component = fixture.componentInstance; const componentRef = fixture.componentRef; - componentRef.setInput("judgement", EntailmentLabel.ENTAILMENT); + componentRef.setInput("entailmentLabel", EntailmentLabel.ENTAILMENT); fixture.detectChanges(); }); diff --git a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html index 8c46b72..f0dd55d 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html +++ b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html @@ -4,15 +4,15 @@
| ID: | +ID: | {{ details.problemId }} | |||||||
| Dataset: | +Dataset: | {{ datasetLabels[details.dataset] }} | |||||||
| Entailment label: | +Entailment label: |
| Section: |
+ Section: |
{{ sectionString() }} |
+ |
Comment:
| Labels: |
+
+ |
+ |
| Label | +Description | +Action | +
|---|---|---|
|
+
+ {{ label.text }}
+
+ |
+
+ {{ label.description }}
+ @if (getAttachedByText(label)) {
+
+ {{ getAttachedByText(label) }}
+
+ }
+ |
+ + @if (label.removable) { + Click to remove + } + | +
| + No labels are currently attached. + | +||
| Label | +Description | +Action | +
|---|---|---|
|
+
+ Loading labels...
+
+ |
+ ||
|
+
+ {{ label.text }}
+
+ |
+ {{ label.description }} | ++ Click to add + | +
| + All labels have been attached. + | +||