diff --git a/README.md b/README.md
index e4bf7d3..0099dcb 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,69 @@
-# LangPro Annotator
-
-[](https://github.com/CentreForDigitalHumanities/langpro-annotator/actions)
-
-An annotation tool for LangPro, a tableau-based theorem prover for natural logic and language.
-
-*This repository is a work in progress, and the README is not yet complete!*
-
-## Introduction
-
-*In this section, provide an overview of your code and describe the project in which the code was developed. Highlight the purpose, scope, and potential uses of your code. Also, consider including links to relevant publications or resources that provide additional context.*
-
-## Getting started
-
-LangPro Annotator is a web application: it can be accessed using a web browser.
-
-*If you are hosting LangPro Annotator anywhere, provide a URL and any additional info.*
-
-You can also run LangPro Annotator locally or host it yourself. Be aware that this is a more advanced option. See [CONTRIBUTING.md](./CONTRIBUTING.md) for information about setting up a local server or configuring deployment.
-
-## Usage
-
-*Provide information about what LangPro Annotator can be used for. If you have written a user manual, this is the place to link it!*
-
-## Development
-
-To get started with developing LangPro Annotator, see [CONTRIBUTING.md](./CONTRIBUTING.md).
-
-## Licence
-
-This work is shared under a BSD 3-Clause licence. See [LICENSE](./LICENSE) for more information.
-
-## Citation
-
-To cite this repository, please use the metadata provided in [CITATION.cff](./CITATION.cff).
-
-## Contact
-
-LangPro Annotator is developed by [Research Software Lab, Centre for Digital Humanities, Utrecht University](https://cdh.uu.nl/about/research-software-lab/).
-
-*Include contact information. You can also provide clear instructions for how users can provide feedback, contribute, or suggest improvements to your work.*
+# LangPro Annotator
+
+[](https://github.com/CentreForDigitalHumanities/langpro-annotator/actions)
+
+An annotation tool for LangPro, a tableau-based theorem prover for natural logic and language.
+
+*This repository is a work in progress, and the README is not yet complete!*
+
+## Introduction
+
+*In this section, provide an overview of your code and describe the project in which the code was developed. Highlight the purpose, scope, and potential uses of your code. Also, consider including links to relevant publications or resources that provide additional context.*
+
+## Getting started
+
+LangPro Annotator is a web application: it can be accessed using a web browser.
+
+*If you are hosting LangPro Annotator anywhere, provide a URL and any additional info.*
+
+You can also run LangPro Annotator locally or host it yourself. Be aware that this is a more advanced option. See [CONTRIBUTING.md](./CONTRIBUTING.md) for information about setting up a local server or configuring deployment.
+
+## Usage
+
+*Provide information about what LangPro Annotator can be used for. If you have written a user manual, this is the place to link it!*
+
+## Development
+
+To get started with developing LangPro Annotator, see [CONTRIBUTING.md](./CONTRIBUTING.md).
+
+## Permission overview
+
+Apart from superusers/admins, the application has three user roles with different permissions. Users with the "Master Annotator" role have full permissions, including managing users and problem visibility/status. Users with the "Annotator" role can browse and annotate both gold and silver problems. Users without any assigned role are considered "Visitors" and can only browse gold problems.
+
+Users can become Annotators or Master Annotators by adding them to the respective user groups in the Django admin interface.
+
+The matrix below shows the permissions for each role. Not all permissions are currently implemented.
+
+(Last updated: November 14th, 2025)
+
+| | Visitor | Annotator | Master Annotator |
+| -------------------------- | ------- | --------- | ---------------- |
+| Browse gold problems | Yes | Yes | Yes |
+| Browse silver problems | No | Yes | Yes |
+| Edit KB items | No | Yes | Yes |
+| Add labels | No | Yes | Yes |
+| Remove own labels | No | Yes | Yes |
+| Remove other users' labels | No | No | Yes |
+| Add problems | No | No | Yes |
+| Copy problems | No | No | Yes |
+| Update user problems | No | No | Yes |
+| Delete problems | No | No | Yes |
+| Edit existing problems | No | No | Yes |
+| See hidden problems | No | No | Yes |
+| Silver/gold problems | No | No | Yes |
+| Hide/unhide problems | No | No | Yes |
+| Manage users | No | No | Yes |
+
+## Licence
+
+This work is shared under a BSD 3-Clause licence. See [LICENSE](./LICENSE) for more information.
+
+## Citation
+
+To cite this repository, please use the metadata provided in [CITATION.cff](./CITATION.cff).
+
+## Contact
+
+LangPro Annotator is developed by [Research Software Lab, Centre for Digital Humanities, Utrecht University](https://cdh.uu.nl/about/research-software-lab/).
+
+*Include contact information. You can also provide clear instructions for how users can provide feedback, contribute, or suggest improvements to your work.*
diff --git a/backend/problem/management/commands/import_fracas.py b/backend/problem/management/commands/import_fracas.py
index 45ecdec..f5a8970 100644
--- a/backend/problem/management/commands/import_fracas.py
+++ b/backend/problem/management/commands/import_fracas.py
@@ -43,6 +43,9 @@ def _annotate_section_subsections(tree: ET.ElementTree) -> None:
root = tree.getroot()
+ if root is None:
+ raise ValueError("The XML file is empty or malformed.")
+
for element in root:
if element.tag == "comment" and element.attrib.get("class") == "section":
current_section = element.text.strip() if element.text else None
@@ -107,15 +110,15 @@ def import_fracas_problems(self, fracas_path: str) -> None:
)
skipped += 1
continue
- hypothesis = Sentence.objects.create(
+ hypothesis = Sentence.objects.update_or_create(
text=FracasData._text_from_element(hypothesis_node)
- )
+ )[0]
premises = []
premise_nodes = problem.findall("p")
for node in premise_nodes:
if node.text:
- premises.append(Sentence.objects.create(text=node.text.strip()))
+ premises.append(Sentence.objects.update_or_create(text=node.text.strip())[0])
entailment_label = self.ENTAILMENT_LABELS.get(
diff --git a/backend/problem/management/commands/import_sick.py b/backend/problem/management/commands/import_sick.py
index 0172d34..07cfade 100644
--- a/backend/problem/management/commands/import_sick.py
+++ b/backend/problem/management/commands/import_sick.py
@@ -57,9 +57,8 @@ def import_sick_problems(self, sick_path: str) -> None:
extra_data = SickData.import_data(problem)
- premise = Sentence.objects.create(text=problem["sentence_A"])
-
- hypothesis = Sentence.objects.create(text=problem["sentence_B"])
+ premise = Sentence.objects.update_or_create(text=problem["sentence_A"])[0]
+ hypothesis = Sentence.objects.update_or_create(text=problem["sentence_B"])[0]
problem = Problem.objects.create(
dataset=Problem.Dataset.SICK,
diff --git a/backend/problem/management/commands/import_snli.py b/backend/problem/management/commands/import_snli.py
index c06c864..ce95f3d 100644
--- a/backend/problem/management/commands/import_snli.py
+++ b/backend/problem/management/commands/import_snli.py
@@ -1,4 +1,5 @@
import csv
+from typing import Literal
from django.core.management.base import BaseCommand
from tqdm import tqdm
@@ -53,7 +54,9 @@ def handle(self, *args, **options):
self.import_snli_problems(snli_paths)
- def import_snli_problems(self, snli_paths: list[tuple[str, str]]) -> None:
+ def import_snli_problems(
+ self, snli_paths: list[tuple[Literal["dev", "train", "test"], str]]
+ ) -> None:
"""
Import SNLI 1.0 problems from a list of SNLI TSV files and enter them into the database.
"""
@@ -99,9 +102,13 @@ def import_snli_problems(self, snli_paths: list[tuple[str, str]]) -> None:
extra_data = SNLIData.import_data(problem, subset)
- premise = Sentence.objects.create(text=problem["sentence1"])
+ premise = Sentence.objects.update_or_create(
+ text=problem["sentence1"]
+ )[0]
- hypothesis = Sentence.objects.create(text=problem["sentence2"])
+ hypothesis = Sentence.objects.update_or_create(
+ text=problem["sentence2"]
+ )[0]
new_problem = Problem.objects.create(
dataset=Problem.Dataset.SNLI,
diff --git a/backend/problem/migrations/0005_problem_base.py b/backend/problem/migrations/0005_problem_base.py
new file mode 100644
index 0000000..b232649
--- /dev/null
+++ b/backend/problem/migrations/0005_problem_base.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.20 on 2025-09-22 11:47
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("problem", "0004_sentence_remove_problem_premises_knowledgebase_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="problem",
+ name="base",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="The base problem from which this problem was derived, if any.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="derived_problems",
+ to="problem.problem",
+ ),
+ ),
+ ]
diff --git a/backend/problem/migrations/0006_alter_problem_options.py b/backend/problem/migrations/0006_alter_problem_options.py
new file mode 100644
index 0000000..9b160ff
--- /dev/null
+++ b/backend/problem/migrations/0006_alter_problem_options.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.27 on 2025-12-08 13:23
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("problem", "0005_problem_base"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="problem",
+ options={
+ "permissions": [
+ ("view_gold_problems", "Can view problems marked as gold"),
+ ("view_silver_problems", "Can view problems marked as silver"),
+ ("view_hidden_problems", "Can view hidden problems that are marked as hidden"),
+ ("copy_problems", "Can copy problems"),
+ ("change_problem_status", "Can change status of a problem (gold/silver)"),
+ ("change_problem_visibility", "Can change problem visibility (hidden/visible)"),
+ ]
+ },
+ ),
+ ]
diff --git a/backend/problem/models.py b/backend/problem/models.py
index b95916e..de54e51 100644
--- a/backend/problem/models.py
+++ b/backend/problem/models.py
@@ -28,6 +28,15 @@ class EntailmentLabel(models.TextChoices):
default=Dataset.USER,
)
+ base = models.ForeignKey(
+ "self",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="derived_problems",
+ help_text="The base problem from which this problem was derived, if any.",
+ )
+
premises = models.ManyToManyField(
Sentence,
related_name="premise_problems",
@@ -47,6 +56,16 @@ class EntailmentLabel(models.TextChoices):
extra_data = models.JSONField()
+ class Meta:
+ permissions = [
+ ("view_gold_problems", "Can view gold problems"),
+ ("view_silver_problems", "Can view silver problems"),
+ ("view_hidden_problems", "Can view hidden problems"),
+ ("copy_problems", "Can copy problems"),
+ ("change_problem_status", "Can change problem status"),
+ ("change_problem_visibility", "Can change problem visibility"),
+ ]
+
def get_index(self, qs: QuerySet) -> int | None:
"""
Get the index of this Problem in a given queryset of problems, ordered by pk.
diff --git a/backend/problem/serializers.py b/backend/problem/serializers.py
index 10eca28..4d89f59 100644
--- a/backend/problem/serializers.py
+++ b/backend/problem/serializers.py
@@ -62,6 +62,7 @@ class Meta:
"entailmentLabel",
"extraData",
"kbItems",
+ "base",
]
def get_premises(self, problem):
@@ -104,6 +105,7 @@ def create(self, validated_data: dict) -> Problem:
)[0]
problem = Problem.objects.create(
+ base_id=validated_data.get("base", None),
hypothesis=hypothesis_sentence,
dataset=Problem.Dataset.USER,
# TODO: Determine entailment label based on LangPro parser output.
@@ -132,6 +134,19 @@ def update(self, instance: Problem, validated_data: dict) -> Problem:
instance.hypothesis = Sentence.objects.get_or_create(
text=validated_data["hypothesis"],
)[0]
+
+ validated_base_id = validated_data.get("base", None)
+ if validated_base_id is None:
+ instance.base = None
+ else:
+ try:
+ base_problem = Problem.objects.get(id=validated_base_id)
+ except Problem.DoesNotExist:
+ raise serializers.ValidationError(
+ f"Base problem with ID {validated_base_id} does not exist."
+ )
+ instance.base = base_problem # type: ignore
+
instance.save()
premise_sentences = [
@@ -188,6 +203,8 @@ class ProblemInputSerializer(serializers.Serializer):
many=True, allow_empty=True, help_text="List of knowledge base items"
)
+ base = serializers.IntegerField(required=False, allow_null=True)
+
def validate_id(self, value):
"""Validate that the Problem ID, if provided, exists and belongs to a user-created problem."""
if value is not None:
@@ -198,3 +215,12 @@ def validate_id(self, value):
f"Problem with ID {value} does not exist."
)
return value
+
+ def validate_base(self, value):
+ """Validate that the base problem ID exists if provided."""
+ if value is not None:
+ if not Problem.objects.filter(id=value).exists():
+ raise serializers.ValidationError(
+ f"Base problem with ID {value} does not exist."
+ )
+ return value
diff --git a/backend/problem/views/problem.py b/backend/problem/views/problem.py
index f4f185a..8ad685d 100644
--- a/backend/problem/views/problem.py
+++ b/backend/problem/views/problem.py
@@ -12,12 +12,30 @@
)
from problem.models import Problem
from problem.serializers import ProblemInputSerializer, ProblemSerializer
+from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
+
+
+class CreateProblemPermission(IsAuthenticated):
+ def has_permission(self, request, view):
+ return super().has_permission(request, view) and request.user.can_create_problem
+
+
+class EditProblemPermission(IsAuthenticated):
+ def has_permission(self, request, view):
+ return super().has_permission(request, view) and request.user.can_edit_problem
class ProblemView(ModelViewSet):
queryset = Problem.objects.all()
serializer_class = ProblemSerializer
+ def get_permissions(self):
+ if self.action == "create":
+ return [CreateProblemPermission()]
+ if self.action == "partial_update":
+ return [EditProblemPermission()]
+ return [IsAuthenticatedOrReadOnly()]
+
def list(self, request: Request) -> Response:
"""
Lists all Problems in the database, with optional filtering.
@@ -55,7 +73,7 @@ def _get_problem_response(self, request: Request, pk: int | None) -> Response:
qs = self.get_queryset()
if filters is not None:
- qs = qs.filter(filters)
+ qs = qs.filter(filters).distinct()
problem = None
if pk is not None:
@@ -111,7 +129,7 @@ def _handle_update_create_problem(
problem_serializer = ProblemSerializer()
if problem_id is None:
- problem = problem_serializer.create(validated_input)
+ problem = problem_serializer.create(validated_input) # type: ignore
status = HTTP_201_CREATED
else:
problem_instance = get_object_or_404(
@@ -123,4 +141,3 @@ def _handle_update_create_problem(
status = HTTP_200_OK
return Response({"id": problem.pk}, status=status)
-
diff --git a/backend/problem/views/problem_test.py b/backend/problem/views/problem_test.py
new file mode 100644
index 0000000..1f53727
--- /dev/null
+++ b/backend/problem/views/problem_test.py
@@ -0,0 +1,303 @@
+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():
+ """Returns valid input data for creating/updating a problem."""
+ return {
+ "premises": ["Test premise 1", "Test premise 2"],
+ "hypothesis": "Test hypothesis",
+ "entailmentLabel": "neutral",
+ "kbItems": [],
+ }
+
+
+class TestProblemViewPermissions:
+ """
+ Tests for problem view permissions as documented in the README.
+ """
+
+ # List / browse
+
+ def test_unauthenticated_user_can_list_problems(self, client, sample_problem):
+ """Unauthenticated users should be able to browse problems (read-only)."""
+ response = client.get("/api/problem/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_visitor_can_list_problems(self, client, visitor, sample_problem):
+ """Visitors should be able to browse problems."""
+ client.force_login(user=visitor)
+ response = client.get("/api/problem/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_annotator_can_list_problems(self, client, annotator, sample_problem):
+ """Annotators should be able to browse problems."""
+ client.force_login(user=annotator)
+ response = client.get("/api/problem/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_master_annotator_can_list_problems(
+ self, client, master_annotator, sample_problem
+ ):
+ """Master annotators should be able to browse problems."""
+ client.force_login(user=master_annotator)
+ response = client.get("/api/problem/")
+ assert response.status_code == status.HTTP_200_OK
+
+ # Retrieve / browse single
+
+ def test_unauthenticated_user_can_retrieve_problem(self, client, sample_problem):
+ """Unauthenticated users should be able to view a single problem."""
+ response = client.get(f"/api/problem/{sample_problem.id}/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_visitor_can_retrieve_problem(self, client, visitor, sample_problem):
+ """Visitors should be able to view a single problem."""
+ client.force_login(user=visitor)
+ response = client.get(f"/api/problem/{sample_problem.id}/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_annotator_can_retrieve_problem(self, client, annotator, sample_problem):
+ """Annotators should be able to view a single problem."""
+ client.force_login(user=annotator)
+ response = client.get(f"/api/problem/{sample_problem.id}/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_master_annotator_can_retrieve_problem(
+ self, client, master_annotator, sample_problem
+ ):
+ """Master annotators should be able to view a single problem."""
+ client.force_login(user=master_annotator)
+ response = client.get(f"/api/problem/{sample_problem.id}/")
+ assert response.status_code == status.HTTP_200_OK
+
+ # Create
+
+ def test_unauthenticated_user_cannot_create_problem(
+ self, client, problem_input_data
+ ):
+ """Unauthenticated users should not be able to create problems."""
+ response = client.post(
+ "/api/problem/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_visitor_cannot_create_problem(self, client, visitor, problem_input_data):
+ """Visitors should not be able to create problems."""
+ client.force_login(user=visitor)
+ response = client.post(
+ "/api/problem/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_annotator_cannot_create_problem(
+ self, client, annotator, problem_input_data
+ ):
+ """Annotators should not be able to create problems."""
+ client.force_login(user=annotator)
+ response = client.post(
+ "/api/problem/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_master_annotator_can_create_problem(
+ self, client, master_annotator, problem_input_data
+ ):
+ """Master annotators should be able to create problems."""
+ client.force_login(user=master_annotator)
+ response = client.post(
+ "/api/problem/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ assert "id" in response.json()
+
+ # Update
+
+ def test_unauthenticated_user_cannot_update_problem(
+ self, client, sample_problem, problem_input_data
+ ):
+ """Unauthenticated users should not be able to update problems."""
+ response = client.patch(
+ f"/api/problem/{sample_problem.id}/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_visitor_cannot_update_problem(
+ self, client, visitor, sample_problem, problem_input_data
+ ):
+ """Visitors should not be able to update problems."""
+ client.force_login(user=visitor)
+ response = client.patch(
+ f"/api/problem/{sample_problem.id}/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_annotator_cannot_update_problem(
+ self, client, annotator, sample_problem, problem_input_data
+ ):
+ """Annotators should not be able to update problems."""
+ client.force_login(user=annotator)
+ response = client.patch(
+ f"/api/problem/{sample_problem.id}/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_master_annotator_can_update_problem(
+ self, client, master_annotator, sample_problem, problem_input_data
+ ):
+ """Master annotators should be able to update user-created problems."""
+ client.force_login(user=master_annotator)
+ response = client.patch(
+ f"/api/problem/{sample_problem.id}/",
+ problem_input_data,
+ content_type="application/json",
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+
+class TestUserRoleProperties:
+ """Tests for user role property methods used by permissions."""
+
+ def test_visitor_cannot_create_problem(self, visitor):
+ """Visitor's can_create_problem should return False."""
+ assert visitor.can_create_problem is False
+
+ def test_visitor_cannot_edit_problem(self, visitor):
+ """Visitor's can_edit_problem should return False."""
+ assert visitor.can_edit_problem is False
+
+ def test_annotator_cannot_create_problem(self, annotator):
+ """Annotator's can_create_problem should return False."""
+ assert annotator.can_create_problem is False
+
+ def test_annotator_cannot_edit_problem(self, annotator):
+ """Annotator's can_edit_problem should return False."""
+ assert annotator.can_edit_problem is False
+
+ def test_master_annotator_can_create_problem(self, master_annotator):
+ """Master annotator's can_create_problem should return True."""
+ assert master_annotator.can_create_problem is True
+
+ def test_master_annotator_can_edit_problem(self, master_annotator):
+ """Master annotator's can_edit_problem should return True."""
+ assert master_annotator.can_edit_problem is True
+
+
+class TestFirstEndpointPermissions:
+ """Tests for the /first endpoint permissions."""
+
+ def test_unauthenticated_user_can_access_first(self, client, sample_problem):
+ """Unauthenticated users should be able to access /first endpoint."""
+ response = client.get("/api/problem/first/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_visitor_can_access_first(self, client, visitor, sample_problem):
+ """Visitors should be able to access /first endpoint."""
+ client.force_login(user=visitor)
+ response = client.get("/api/problem/first/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_annotator_can_access_first(self, client, annotator, sample_problem):
+ """Annotators should be able to access /first endpoint."""
+ client.force_login(user=annotator)
+ response = client.get("/api/problem/first/")
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_master_annotator_can_access_first(
+ self, client, master_annotator, sample_problem
+ ):
+ """Master annotators should be able to access /first endpoint."""
+ client.force_login(user=master_annotator)
+ response = client.get("/api/problem/first/")
+ assert response.status_code == status.HTTP_200_OK
diff --git a/backend/user/admin.py b/backend/user/admin.py
index 704eef0..5fb51a0 100644
--- a/backend/user/admin.py
+++ b/backend/user/admin.py
@@ -1,8 +1,9 @@
-from django.contrib import admin
-from django.contrib.auth import admin as auth_admin
-from . import models
-
-
-@admin.register(models.User)
-class UserAdmin(auth_admin.UserAdmin):
- pass
+from django.contrib import admin
+from django.contrib.auth import admin as auth_admin
+from . import models
+from django.utils.translation import gettext_lazy as _
+
+
+@admin.register(models.User)
+class UserAdmin(auth_admin.UserAdmin):
+ list_display = ("username", "first_name", "email", "last_name", "is_staff")
diff --git a/backend/user/migrations/0004_create_user_groups.py b/backend/user/migrations/0004_create_user_groups.py
new file mode 100644
index 0000000..ea9c7b6
--- /dev/null
+++ b/backend/user/migrations/0004_create_user_groups.py
@@ -0,0 +1,61 @@
+# Generated by Django 4.2.27 on 2025-12-08 13:25
+
+from django.db import migrations
+
+from user.models import GroupName
+from user.permissions import ANNOTATOR_PERMISSIONS, MASTER_ANNOTATOR_PERMISSIONS
+
+
+PERMISSION_MAP = {
+ GroupName.MASTER_ANNOTATORS: MASTER_ANNOTATOR_PERMISSIONS,
+ GroupName.ANNOTATORS: ANNOTATOR_PERMISSIONS,
+}
+
+
+def create_groups(apps, schema_editor):
+ """
+ Creates user groups for annotators and master annotators and assigns permissions to them.
+ """
+ Group = apps.get_model("auth", "Group")
+ Permission = apps.get_model("auth", "Permission")
+
+ for group_name, perms in PERMISSION_MAP.items():
+ group, created = Group.objects.get_or_create(name=group_name.value)
+ for perm_codename in perms:
+ try:
+ app_label, codename = perm_codename
+ permission = Permission.objects.get(
+ content_type__app_label=app_label,
+ codename=codename,
+ )
+ group.permissions.add(permission)
+ except Permission.DoesNotExist:
+ print(
+ f"Permission {perm_codename} does not exist and cannot be added to group {group_name}."
+ )
+
+
+def delete_groups(apps, schema_editor):
+ """
+ Deletes the user groups created for annotators and master annotators.
+ """
+ Group = apps.get_model("auth", "Group")
+
+ for group_name in PERMISSION_MAP.keys():
+ try:
+ group = Group.objects.get(name=group_name)
+ group.delete()
+ except Group.DoesNotExist:
+ print(f"Group {group_name} does not exist and cannot be deleted.")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("user", "0003_sitedomain"),
+ ("problem", "0006_alter_problem_options"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_groups, delete_groups),
+ ]
diff --git a/backend/user/models.py b/backend/user/models.py
index a3d8413..6add7f9 100644
--- a/backend/user/models.py
+++ b/backend/user/models.py
@@ -1,14 +1,57 @@
-import django.contrib.auth.models as django_auth_models
-
-
-class User(django_auth_models.AbstractUser):
- """
- Core user model used for authentication.
- """
-
- # Only extend this model with information that is relevant for
- # authentication; for things like settings and preferences, add
- # a UserProfile model.
-
- class Meta:
- db_table = "auth_user"
+from enum import StrEnum
+import django.contrib.auth.models as django_auth_models
+
+
+class GroupName(StrEnum):
+ MASTER_ANNOTATORS = "Master Annotators"
+ ANNOTATORS = "Annotators"
+
+
+class UserRole(StrEnum):
+ SUPERUSER = "superuser"
+ MASTER_ANNOTATOR = "master_annotator"
+ ANNOTATOR = "annotator"
+ VISITOR = "visitor"
+
+
+class User(django_auth_models.AbstractUser):
+ """
+ Core user model used for authentication.
+ """
+
+ # Only extend this model with information that is relevant for
+ # authentication; for things like settings and preferences, add
+ # a UserProfile model.
+
+ class Meta:
+ db_table = "auth_user"
+
+ @property
+ def role(self) -> str:
+ """
+ Returns the role of the user based on their group membership.
+
+ Currently only used in the frontend to pick a user icon.
+ """
+ if self.is_superuser:
+ return UserRole.SUPERUSER
+ elif self.groups.filter(name=GroupName.MASTER_ANNOTATORS).exists():
+ return UserRole.MASTER_ANNOTATOR
+ elif self.groups.filter(name=GroupName.ANNOTATORS).exists():
+ return UserRole.ANNOTATOR
+ else:
+ return UserRole.VISITOR
+
+ @property
+ def can_edit_problem(self) -> bool:
+ """
+ Determines whether the user can edit problems.
+ """
+ return self.has_perm("problem.change_problem")
+
+ @property
+ def can_create_problem(self) -> bool:
+ """
+ Determines whether the user can create new problems.
+ """
+ return self.has_perm("problem.add_problem")
diff --git a/backend/user/permissions.py b/backend/user/permissions.py
new file mode 100644
index 0000000..96f29c6
--- /dev/null
+++ b/backend/user/permissions.py
@@ -0,0 +1,19 @@
+# Django permissions are uniquely identified by their combination of a `content_type__app_label` and a `codename`.
+ANNOTATOR_PERMISSIONS = [
+ ("problem", "view_silver_problems"),
+ ("problem", "add_knowledgebase"),
+ ("problem", "change_knowledgebase"),
+ ("problem", "delete_knowledgebase"),
+ ("problem", "view_knowledgebase"),
+]
+
+MASTER_ANNOTATOR_PERMISSIONS = ANNOTATOR_PERMISSIONS + [
+ ("problem", "copy_problems"),
+ ("problem", "view_hidden_problems"),
+ ("problem", "change_problem_status"),
+ ("problem", "change_problem_visibility"),
+ ("problem", "add_problem"),
+ ("problem", "change_problem"),
+ ("problem", "delete_problem"),
+ ("problem", "view_problem"),
+]
diff --git a/backend/user/serializers.py b/backend/user/serializers.py
index 4886ee4..4f2ee5f 100644
--- a/backend/user/serializers.py
+++ b/backend/user/serializers.py
@@ -1,17 +1,28 @@
-from dj_rest_auth.serializers import UserDetailsSerializer
-from rest_framework import serializers
-
-
-class CustomUserDetailsSerializer(UserDetailsSerializer):
-
- class Meta(UserDetailsSerializer.Meta):
- is_staff = serializers.BooleanField(read_only=True)
- fields = (
- "id",
- "username",
- "email",
- "first_name",
- "last_name",
- "is_staff",
- )
- read_only_fields = ["is_staff", "id", "email"]
+from dj_rest_auth.serializers import UserDetailsSerializer
+from rest_framework import serializers
+
+
+class CustomUserDetailsSerializer(UserDetailsSerializer):
+ firstName = serializers.CharField(source="first_name")
+ lastName = serializers.CharField(source="last_name")
+ isStaff = serializers.BooleanField(read_only=True, source="is_staff")
+ role = serializers.CharField(read_only=True)
+ canEditProblem = serializers.BooleanField(read_only=True, source="can_edit_problem")
+ canCreateProblem = serializers.BooleanField(
+ read_only=True, source="can_create_problem"
+ )
+
+ class Meta(UserDetailsSerializer.Meta):
+
+ fields = (
+ "id",
+ "username",
+ "email",
+ "firstName",
+ "lastName",
+ "isStaff",
+ "role",
+ "canEditProblem",
+ "canCreateProblem",
+ )
+ read_only_fields = ["isStaff", "id", "email"]
diff --git a/backend/user/tests/test_user_views.py b/backend/user/tests/test_user_views.py
index babe923..5731cc5 100644
--- a/backend/user/tests/test_user_views.py
+++ b/backend/user/tests/test_user_views.py
@@ -1,37 +1,40 @@
-from unittest.mock import ANY
-
-
-def test_user_details(user_client, user_data):
- details = user_client.get("/users/user/")
- assert details.status_code == 200
- assert details.data == {
- "id": ANY,
- "username": user_data["username"],
- "email": user_data["email"],
- "first_name": user_data["first_name"],
- "last_name": user_data["last_name"],
- "is_staff": False,
- }
-
-
-def test_user_updates(user_client, user_data):
- route = "/users/user/"
- details = lambda: user_client.get(route).data
- assert details()["username"] == user_data["username"]
-
- # update username should succeed
- response = user_client.patch(
- route,
- {"username": "NewName"},
- content_type="application/json",
- )
- assert response.status_code == 200
- assert details()["username"] == "NewName"
-
- # is_staff is readonly, so nothing should happen
- response = user_client.patch(
- route,
- {"is_staff": True},
- content_type="application/json",
- )
- assert not details()["is_staff"]
+from unittest.mock import ANY
+
+
+def test_user_details(user_client, user_data):
+ details = user_client.get("/users/user/")
+ assert details.status_code == 200
+ assert details.data == {
+ "id": ANY,
+ "username": user_data["username"],
+ "email": user_data["email"],
+ "firstName": user_data["first_name"],
+ "lastName": user_data["last_name"],
+ "isStaff": False,
+ "role": "visitor",
+ "canCreateProblem": False,
+ "canEditProblem": False,
+ }
+
+
+def test_user_updates(user_client, user_data):
+ route = "/users/user/"
+ details = lambda: user_client.get(route).data
+ assert details()["username"] == user_data["username"]
+
+ # update username should succeed
+ response = user_client.patch(
+ route,
+ {"username": "NewName"},
+ content_type="application/json",
+ )
+ assert response.status_code == 200
+ assert details()["username"] == "NewName"
+
+ # isStaff is readonly, so nothing should happen
+ response = user_client.patch(
+ route,
+ {"isStaff": True},
+ content_type="application/json",
+ )
+ assert not details()["isStaff"]
diff --git a/frontend/src/app/annotate/annotate.component.html b/frontend/src/app/annotate/annotate.component.html
index 4441494..ce0e6a1 100644
--- a/frontend/src/app/annotate/annotate.component.html
+++ b/frontend/src/app/annotate/annotate.component.html
@@ -4,7 +4,6 @@
@let adding = appMode === 'add';
@let editing = appMode === 'edit';
-
@if (browsing) {
@@ -12,44 +11,32 @@
- } @else if (adding) {
+ } @else if (adding || editing) {
+ @if (adding) {
You are currently adding a new problem.
-
- Hit 'Start Parse' to send your problem data to the parser and
- inspect the results.
-
-
- If you are happy with the changes, save the problem by clicking
- the 'Save' button.
-
-
- This will add the problem to the database.
-
-
- } @else if (editing) {
-
+ } @else {
You are currently editing an existing problem.
+ }
Hit 'Start Parse' to send your problem data to the parser and
inspect the results.
- If you are happy with the changes, save the changes by clicking
+ If you are happy with the changes, save the problem by clicking
the 'Save problem' button.
- @if (isUserProblem$ | async) {
+ @if (adding) {
- This will update this problem in the database.
+ This will add the problem to the database.
} @else {
- This will create a new problem in the database, leaving the
- original problem unchanged.
+ This will update this problem in the database.