Skip to content

Commit a1bacd9

Browse files
Merge pull request #64 from CentreForDigitalHumanities/feature/problem-labels
Feature/problem labels
2 parents a1a2d38 + eafc758 commit a1bacd9

34 files changed

Lines changed: 1726 additions & 117 deletions

backend/annotation/admin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
from django.contrib import admin
22

3+
from annotation.models import Label
4+
5+
36
# Register your models here.
7+
@admin.register(Label)
8+
class LabelAdmin(admin.ModelAdmin):
9+
list_display = ("text", "description")
10+
search_fields = ("text",)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
[
2+
{
3+
"model": "annotation.label",
4+
"pk": 1,
5+
"fields": {
6+
"text": "Important",
7+
"description": "This problem is particularly important."
8+
}
9+
},
10+
{
11+
"model": "annotation.label",
12+
"pk": 2,
13+
"fields": {
14+
"text": "Review needed",
15+
"description": "This problem needs to be reviewed by a master annotator."
16+
}
17+
},
18+
{
19+
"model": "annotation.label",
20+
"pk": 3,
21+
"fields": {
22+
"text": "Ambiguous",
23+
"description": "The problem statement is ambiguous and needs clarification."
24+
}
25+
},
26+
{
27+
"model": "annotation.label",
28+
"pk": 4,
29+
"fields": {
30+
"text": "Incomplete knowledge",
31+
"description": "Some knowledge base items are missing."
32+
}
33+
},
34+
{
35+
"model": "annotation.label",
36+
"pk": 5,
37+
"fields": {
38+
"text": "Spelling",
39+
"description": "The premises or hypothesis are misspelled, which may lead to wrong analyses."
40+
}
41+
},
42+
{
43+
"model": "annotation.label",
44+
"pk": 6,
45+
"fields": {
46+
"text": "Wrong entailment label",
47+
"description": "The entailment label is incorrect."
48+
}
49+
}
50+
]
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Generated by Django 4.2.27 on 2025-12-11 15:10
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
("problem", "0007_alter_problem_options"),
13+
("annotation", "0001_initial"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="Label",
19+
fields=[
20+
(
21+
"id",
22+
models.BigAutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
("text", models.CharField(max_length=255)),
30+
(
31+
"description",
32+
models.TextField(
33+
help_text="A detailed description of the label, indicating when it should be used."
34+
),
35+
),
36+
],
37+
options={
38+
"ordering": ["text"],
39+
},
40+
),
41+
migrations.CreateModel(
42+
name="Labeling",
43+
fields=[
44+
(
45+
"id",
46+
models.BigAutoField(
47+
auto_created=True,
48+
primary_key=True,
49+
serialize=False,
50+
verbose_name="ID",
51+
),
52+
),
53+
("attached_at", models.DateTimeField(auto_now_add=True)),
54+
(
55+
"removed_at",
56+
models.DateTimeField(
57+
blank=True,
58+
help_text="When this label was removed from the problem.",
59+
null=True,
60+
),
61+
),
62+
(
63+
"notes",
64+
models.TextField(
65+
blank=True,
66+
help_text="Optional notes explaining why this label was added or removed.",
67+
),
68+
),
69+
(
70+
"attached_by",
71+
models.ForeignKey(
72+
on_delete=django.db.models.deletion.CASCADE,
73+
related_name="labelings_attached",
74+
to=settings.AUTH_USER_MODEL,
75+
),
76+
),
77+
(
78+
"label",
79+
models.ForeignKey(
80+
on_delete=django.db.models.deletion.CASCADE,
81+
related_name="labelings",
82+
to="annotation.label",
83+
),
84+
),
85+
(
86+
"problem",
87+
models.ForeignKey(
88+
on_delete=django.db.models.deletion.CASCADE,
89+
related_name="labelings",
90+
to="problem.problem",
91+
),
92+
),
93+
(
94+
"removed_by",
95+
models.ForeignKey(
96+
blank=True,
97+
help_text="User who removed this label.",
98+
null=True,
99+
on_delete=django.db.models.deletion.CASCADE,
100+
related_name="labelings_removed",
101+
to=settings.AUTH_USER_MODEL,
102+
),
103+
),
104+
],
105+
options={
106+
"ordering": ["-attached_at"],
107+
"permissions": [
108+
("delete_own_labeling", "Can remove own labeling from problems"),
109+
("delete_any_labeling", "Can remove any labeling from problems"),
110+
],
111+
"indexes": [
112+
models.Index(
113+
fields=["problem", "removed_at"],
114+
name="annotation__problem_e7ac7b_idx",
115+
),
116+
models.Index(
117+
fields=["label", "removed_at"],
118+
name="annotation__label_i_a229d2_idx",
119+
),
120+
],
121+
},
122+
),
123+
]

backend/annotation/models.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,83 @@ class KnowledgeBaseAnnotation(models.Model):
8181
)
8282

8383
created_at = models.DateTimeField(auto_now_add=True)
84+
85+
86+
class Label(models.Model):
87+
text = models.CharField(max_length=255)
88+
description = models.TextField(
89+
help_text="A detailed description of the label, indicating when it should be used."
90+
)
91+
92+
class Meta:
93+
ordering = ["text"]
94+
95+
def __str__(self):
96+
return self.text
97+
98+
99+
class Labeling(models.Model):
100+
"""
101+
The attachment of a label to a problem.
102+
103+
Each time a label is attached to a problem, a new Labeling record is created.
104+
When removed, the record is marked as removed (not deleted), so the history of labelings is preserved.
105+
"""
106+
107+
problem = models.ForeignKey(
108+
Problem,
109+
on_delete=models.CASCADE,
110+
related_name="labelings",
111+
)
112+
113+
label = models.ForeignKey(
114+
Label,
115+
on_delete=models.CASCADE,
116+
related_name="labelings",
117+
)
118+
119+
attached_at = models.DateTimeField(auto_now_add=True)
120+
attached_by = models.ForeignKey(
121+
settings.AUTH_USER_MODEL,
122+
on_delete=models.CASCADE,
123+
related_name="labelings_attached",
124+
)
125+
126+
removed_at = models.DateTimeField(
127+
null=True,
128+
blank=True,
129+
help_text="When this label was removed from the problem.",
130+
)
131+
removed_by = models.ForeignKey(
132+
settings.AUTH_USER_MODEL,
133+
on_delete=models.CASCADE,
134+
related_name="labelings_removed",
135+
null=True,
136+
blank=True,
137+
help_text="User who removed this label.",
138+
)
139+
140+
notes = models.TextField(
141+
blank=True,
142+
help_text="Optional notes explaining why this label was added or removed.",
143+
)
144+
145+
class Meta:
146+
ordering = ["-attached_at"]
147+
indexes = [
148+
models.Index(fields=["problem", "removed_at"]),
149+
models.Index(fields=["label", "removed_at"]),
150+
]
151+
permissions = [
152+
("delete_own_labeling", "Can remove own labeling from problems"),
153+
("delete_any_labeling", "Can remove any labeling from problems"),
154+
]
155+
156+
157+
def is_active(self) -> bool:
158+
"""Check if this labeling is currently active (not removed)."""
159+
return self.removed_at is None
160+
161+
def __str__(self):
162+
status = "active" if self.is_active() else f"removed at {self.removed_at}"
163+
return f"Label '{self.label.text}' on Problem {self.problem.pk} ({status})"

backend/annotation/serializers.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from rest_framework import serializers
2+
3+
from django.contrib.auth.models import AnonymousUser
4+
5+
from annotation.models import Label, Labeling
6+
from problem.models import Problem
7+
from user.models import User
8+
9+
10+
class LabelSerializer(serializers.ModelSerializer):
11+
"""
12+
Serializer for Label model.
13+
"""
14+
15+
class Meta:
16+
model = Label
17+
fields = ["id", "text", "description"]
18+
19+
20+
class ActiveLabelSerializer(serializers.Serializer):
21+
"""
22+
Serializer for active labels attached to a problem.
23+
Includes attachedInfo and removable status based on current user.
24+
"""
25+
26+
id = serializers.IntegerField(source="label.id")
27+
text = serializers.CharField(source="label.text")
28+
description = serializers.CharField(source="label.description")
29+
attachedInfo = serializers.SerializerMethodField()
30+
removable = serializers.SerializerMethodField()
31+
32+
def get_attachedInfo(self, labeling: Labeling) -> dict:
33+
"""Get attachment information for the label."""
34+
request = self.context.get("request")
35+
user: User | AnonymousUser | None = request.user if request else None
36+
37+
if user and user.is_anonymous is False:
38+
attached_by_current_user = labeling.attached_by.pk == user.pk
39+
else:
40+
attached_by_current_user = False
41+
42+
return {
43+
"userName": labeling.attached_by.username,
44+
"date": labeling.attached_at.isoformat(),
45+
"attachedByCurrentUser": attached_by_current_user,
46+
}
47+
48+
def get_removable(self, labeling: Labeling) -> bool:
49+
"""Determine if the label is removable by the current user."""
50+
request = self.context.get("request")
51+
user: User | AnonymousUser | None = request.user if request else None
52+
53+
if user is None or user.is_anonymous:
54+
return False
55+
56+
if user.is_superuser or user.has_perm("annotation.delete_any_labeling"):
57+
return True
58+
59+
if user.has_perm("annotation.delete_own_labeling"):
60+
return labeling.attached_by.pk == user.pk
61+
62+
return False
63+
64+
65+
class LabelingSerializer(serializers.ModelSerializer):
66+
"""
67+
Serializer for Labeling model, including the full label details.
68+
"""
69+
70+
label = LabelSerializer(read_only=True)
71+
attachedAt = serializers.DateTimeField(source="attached_at")
72+
attachedBy = serializers.PrimaryKeyRelatedField(
73+
source="attached_by", read_only=True
74+
)
75+
removedAt = serializers.DateTimeField(source="removed_at", allow_null=True)
76+
removedBy = serializers.PrimaryKeyRelatedField(
77+
source="removed_by", allow_null=True, read_only=True
78+
)
79+
80+
class Meta:
81+
model = Labeling
82+
fields = [
83+
"id",
84+
"label",
85+
"attached_at",
86+
"attached_by",
87+
"removed_at",
88+
"removed_by",
89+
"notes",
90+
]
91+
92+
93+
class SelectedLabelSerializer(serializers.Serializer):
94+
"""Serializer for a selected label in the save labels input."""
95+
96+
id = serializers.IntegerField()
97+
98+
def validate_id(self, value):
99+
"""Validate that the label exists."""
100+
if not Label.objects.filter(id=value).exists():
101+
raise serializers.ValidationError(f"Label with ID {value} does not exist.")
102+
return value
103+
104+
105+
class SaveLabelsInputSerializer(serializers.Serializer):
106+
"""
107+
Serializer for validating save labels input data.
108+
Used when saving/updating labels for a problem.
109+
"""
110+
111+
problemId = serializers.IntegerField()
112+
selectedLabels = SelectedLabelSerializer(many=True, allow_empty=True)
113+
remarks = serializers.CharField(required=False, allow_blank=True, default="")
114+
115+
def validate_problemId(self, value):
116+
"""Validate that the problem exists."""
117+
if not Problem.objects.filter(id=value).exists():
118+
raise serializers.ValidationError(
119+
f"Problem with ID {value} does not exist."
120+
)
121+
return value

0 commit comments

Comments
 (0)