diff --git a/README.md b/README.md index e4bf7d3..0099dcb 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,69 @@ -# LangPro Annotator - -[![Actions Status](https://github.com/CentreForDigitalHumanities/langpro-annotator/workflows/Unit%20tests/badge.svg)](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 + +[![Actions Status](https://github.com/CentreForDigitalHumanities/langpro-annotator/workflows/Unit%20tests/badge.svg)](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.

}
@@ -61,7 +48,7 @@ }
- @if (browsing) { + @if (browsing && (canCreateProblem$ | async)) { problem?.dataset === Dataset.USER) ); + public canCreateProblem$ = this.authService.currentUser$.pipe( + map(user => user?.canCreateProblem ?? false) + ); + ngOnInit(): void { const editParam$ = this.route.url.pipe( map(segments => segments.some(segment => segment.path === "edit")), @@ -54,6 +61,7 @@ export class AnnotateComponent implements OnInit { editParam$ ]) .pipe( + distinctUntilChanged((oldParams, newParams) => areParamsEqual(oldParams, newParams)), takeUntilDestroyed(this.destroyRef) ) .subscribe(([params, queryParams, edit]) => { diff --git a/frontend/src/app/annotate/annotation-input/annotation-input.component.html b/frontend/src/app/annotate/annotation-input/annotation-input.component.html index 5a1789d..dd23196 100644 --- a/frontend/src/app/annotate/annotation-input/annotation-input.component.html +++ b/frontend/src/app/annotate/annotation-input/annotation-input.component.html @@ -12,33 +12,36 @@
- + Copy problem + + } @if ((appMode === 'edit' || appMode === 'add') && userProblem) { - - } @else if (userProblem) { + /> + } @else if (userProblem && (canEditProblem$ | async)) { { it('should build form with correct structure and values from problem data', () => { const mockProblem: Problem = { id: 123, + base: null, premises: ["First premise", "Second premise"], hypothesis: "Test hypothesis", entailmentLabel: EntailmentLabel.ENTAILMENT, @@ -104,6 +105,7 @@ describe("AnnotationInputComponent", () => { it('should handle empty premises and kbItems arrays', () => { const mockProblem: Problem = { id: 123, + base: null, premises: [], hypothesis: "Empty test hypothesis", entailmentLabel: EntailmentLabel.NEUTRAL, @@ -127,6 +129,7 @@ describe("AnnotationInputComponent", () => { it('should create form controls with required validators', () => { const mockProblem: Problem = { id: 1, + base: null, premises: ["Test premise"], hypothesis: "Test hypothesis", entailmentLabel: EntailmentLabel.CONTRADICTION, @@ -148,6 +151,7 @@ describe("AnnotationInputComponent", () => { it('should navigate when problem ID is different from current route', () => { const mockProblem: Problem = { id: 12, + base: null, premises: [], hypothesis: "", entailmentLabel: EntailmentLabel.UNKNOWN, @@ -167,6 +171,7 @@ describe("AnnotationInputComponent", () => { it('should not navigate when problem ID matches current route', () => { const mockProblem: Problem = { id: 17, + base: null, premises: [], hypothesis: "", entailmentLabel: EntailmentLabel.UNKNOWN, diff --git a/frontend/src/app/annotate/annotation-input/annotation-input.component.ts b/frontend/src/app/annotate/annotation-input/annotation-input.component.ts index 04dd8fa..3c3a690 100644 --- a/frontend/src/app/annotate/annotation-input/annotation-input.component.ts +++ b/frontend/src/app/annotate/annotation-input/annotation-input.component.ts @@ -12,7 +12,7 @@ import { PremisesFormComponent } from "./premises-form/premises-form.component"; import { KnowledgeBaseFormComponent } from "./knowledge-base-form/knowledge-base-form.component"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Dataset, KnowledgeBaseRelationship, Problem } from "../../types"; -import { faCheck, faExclamationCircle, faFloppyDisk, faTrash, faTree, faWrench } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faCopy, faExclamationCircle, faFloppyDisk, faTrash, faTree, faWrench } from "@fortawesome/free-solid-svg-icons"; import { ProblemDetailsComponent } from "./problem-details/problem-details.component"; import { map, Subject } from "rxjs"; import { ActivatedRoute, Router, RouterLinkWithHref } from "@angular/router"; @@ -20,9 +20,12 @@ import { ProblemService } from "@/services/problem.service"; import { ParseService } from "@/services/parse.service"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { ToastService } from "@/services/toast.service"; +import { AuthService } from "@/services/auth.service"; +import { IconButtonComponent } from "@/shared/icon-button/icon-button.component"; export type ParseInputForm = FormGroup<{ id: FormControl; + base: FormControl; premises: FormArray>; hypothesis: FormControl; kbItems: FormArray; @@ -48,7 +51,8 @@ export type ParseInput = ReturnType; ReactiveFormsModule, ProblemDetailsComponent, FontAwesomeModule, - RouterLinkWithHref + RouterLinkWithHref, + IconButtonComponent ], templateUrl: "./annotation-input.component.html", styleUrl: "./annotation-input.component.scss", @@ -60,6 +64,7 @@ export class AnnotationInputComponent implements OnInit { private problemService = inject(ProblemService); private parseService = inject(ParseService); private toastService = inject(ToastService); + private authService = inject(AuthService); public form: ParseInputForm | null = null; @@ -67,6 +72,7 @@ export class AnnotationInputComponent implements OnInit { public submit$ = new Subject(); + public faCopy = faCopy; public faCheck = faCheck; public faTree = faTree; public faFloppyDisk = faFloppyDisk; @@ -79,6 +85,10 @@ export class AnnotationInputComponent implements OnInit { map(problem => problem?.dataset === Dataset.USER) ); + public canEditProblem$ = this.authService.currentUser$.pipe( + map(user => user?.canEditProblem ?? false) + ); + ngOnInit(): void { this.problemService.problem$ .pipe(takeUntilDestroyed(this.destroyRef)) @@ -151,17 +161,17 @@ export class AnnotationInputComponent implements OnInit { } private buildForm(problem: Problem): ParseInputForm { - const id = problem.id; - const premises = problem.premises || []; - const hypothesis = problem.hypothesis || ""; const kbItems = this.buildKbForms(problem.kbItems); return new FormGroup({ - id: new FormControl(id, { + id: new FormControl(problem.id, { + nonNullable: true + }), + base: new FormControl(problem.base, { nonNullable: true }), premises: new FormArray( - premises.map( + problem.premises.map( (premise) => new FormControl(premise, { validators: [Validators.required], @@ -169,7 +179,7 @@ export class AnnotationInputComponent implements OnInit { }) ) ), - hypothesis: new FormControl(hypothesis, { + hypothesis: new FormControl(problem.hypothesis ?? "", { validators: [Validators.required], nonNullable: true, }), diff --git a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html index 84b2095..7f61af8 100644 --- a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html +++ b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html @@ -8,25 +8,23 @@ > Knowledge base items @if (appMode === 'add' || appMode === 'edit') { - + i18n-label + outline + /> }
    - @for (kbItem of kbControls; let i = $index; track `${i}-${kbItem.value.entity1}-${kbItem.value.relationship}-${kbItem.value.entity2}`) { + @for (kbItem of kbControls; let i = $index; track + `${i}-${kbItem.value.entity1}-${kbItem.value.relationship}-${kbItem.value.entity2}`) + {
  • @if (appMode === 'add' || appMode === 'edit') {
    @@ -54,24 +52,15 @@ placeholder="Entity 2" required /> - + visuallyHiddenLabel + i18n-label + />
    } @else {

    diff --git a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts index 7d16065..259996e 100644 --- a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts +++ b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts @@ -6,6 +6,8 @@ import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { ParseInputForm } from "../annotation-input.component"; import { KnowledgeBaseRelationship } from "@/types"; +import { IconButtonComponent } from "@/shared/icon-button/icon-button.component"; + import { ProblemService } from "@/services/problem.service"; @@ -19,7 +21,7 @@ const relationshipDisplayMapping: Record = { @Component({ selector: "la-knowledge-base-form", standalone: true, - imports: [CommonModule, ReactiveFormsModule, FontAwesomeModule], + imports: [CommonModule, ReactiveFormsModule, FontAwesomeModule, IconButtonComponent], templateUrl: "./knowledge-base-form.component.html", styleUrls: ["./knowledge-base-form.component.scss"], }) diff --git a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html index 2f91624..ecc1ea4 100644 --- a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html +++ b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html @@ -9,19 +9,15 @@ > Premises @if (editingOrAdding) { - + i18n-label + outline + /> } @@ -41,24 +37,14 @@ [formControlName]="i" /> @if (editingOrAdding) { - + visuallyHiddenLabel + i18n-label + /> }

diff --git a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts index a1f700b..244fe35 100644 --- a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts +++ b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts @@ -5,6 +5,7 @@ import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { ParseInputForm } from "../annotation-input.component"; import { ProblemService } from "@/services/problem.service"; +import { IconButtonComponent } from "@/shared/icon-button/icon-button.component"; export interface Premises { premises: string[]; @@ -14,7 +15,7 @@ export interface Premises { @Component({ selector: "la-premises-form", standalone: true, - imports: [ReactiveFormsModule, CommonModule, FontAwesomeModule], + imports: [ReactiveFormsModule, CommonModule, FontAwesomeModule, IconButtonComponent], templateUrl: "./premises-form.component.html", styleUrl: "./premises-form.component.scss", }) 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 e5bf8c5..8c46b72 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 @@ -1,3 +1,4 @@ +@let appMode = appMode$ | async; @if (problemDetails(); as details) {
@@ -18,6 +19,21 @@ > + + + @if (appMode === 'browse' && details.baseProblemId !== null) { + + } @else if (details.baseProblemId === null) { + + } @else { + + } + @if (sectionString()) { diff --git a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts index f135997..380d352 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts +++ b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ProblemDetailsComponent } from "./problem-details.component"; import { Dataset, EntailmentLabel, Problem } from "../../../types"; +import { provideHttpClient } from "@angular/common/http"; const createMockProblem = ( id: number, @@ -10,6 +11,7 @@ const createMockProblem = ( extraData: any = {}, ): Problem => ({ id, + base: null, dataset, entailmentLabel, premises: ["premise"], @@ -25,6 +27,7 @@ describe("ProblemDetailsComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ProblemDetailsComponent], + providers: [provideHttpClient()] }).compileComponents(); fixture = TestBed.createComponent(ProblemDetailsComponent); @@ -41,7 +44,7 @@ describe("ProblemDetailsComponent", () => { expect(component).toBeTruthy(); }); - describe("with SICK dataset problem", () => { + describe("with SICK dataset problem", () => { beforeEach(() => { const problem = createMockProblem( 1, @@ -55,6 +58,7 @@ describe("ProblemDetailsComponent", () => { it("should extract correct problem details", () => { expect(component.problemDetails()).toEqual({ problemId: "1", + baseProblemId: null, dataset: Dataset.SICK, entailmentLabel: EntailmentLabel.ENTAILMENT, section: null, @@ -87,6 +91,7 @@ describe("ProblemDetailsComponent", () => { it("should extract correct problem details", () => { expect(component.problemDetails()).toEqual({ problemId: "2", + baseProblemId: null, dataset: Dataset.FRACAS, entailmentLabel: EntailmentLabel.CONTRADICTION, section: "Quantifiers", diff --git a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts index 2630d79..961d2ce 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts +++ b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts @@ -1,13 +1,17 @@ -import { Component, computed, input } from "@angular/core"; +import { Component, computed, inject, input } from "@angular/core"; import { Dataset, EntailmentLabel, Problem } from "../../../types"; import { EntailmentLabelBadgeComponent } from "./entailment-label-badge/entailment-label-badge.component"; -import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { NgbTooltipModule } from "@ng-bootstrap/ng-bootstrap"; import { datasetLabels } from "@/shared/displayTextMappings"; +import { CommonModule } from "@angular/common"; +import { RouterModule } from "@angular/router"; +import { ProblemService } from "@/services/problem.service"; export interface ProblemDetails { problemId: string; + baseProblemId: string | null; dataset: Dataset; entailmentLabel: EntailmentLabel; section: string | null; @@ -22,12 +26,17 @@ export interface ProblemDetails { EntailmentLabelBadgeComponent, FontAwesomeModule, NgbTooltipModule, + CommonModule, + RouterModule, ], templateUrl: "./problem-details.component.html", styleUrl: "./problem-details.component.scss", }) export class ProblemDetailsComponent { public readonly problem = input.required(); + private problemService = inject(ProblemService); + + public appMode$ = this.problemService.appMode$; public problemDetails = computed(() => { const problem = this.problem(); @@ -38,6 +47,7 @@ export class ProblemDetailsComponent { }); public faQuestionCircle = faQuestionCircle; + public faArrowUpRight = faArrowUpRightFromSquare; public datasetLabels = datasetLabels; public sectionString = computed(() => { @@ -60,9 +70,10 @@ export class ProblemDetailsComponent { private extractDetails(problem: Problem): ProblemDetails | null { const shared: Pick< ProblemDetails, - "problemId" | "dataset" | "entailmentLabel" + "problemId" | "dataset" | "entailmentLabel" | "baseProblemId" > = { problemId: problem.id?.toString() ?? $localize`new`, + baseProblemId: problem.base?.toString() ?? null, dataset: problem.dataset, entailmentLabel: problem.entailmentLabel, }; diff --git a/frontend/src/app/annotate/navigator/navigator.component.html b/frontend/src/app/annotate/navigator/navigator.component.html index 0b28368..ecfdfca 100644 --- a/frontend/src/app/annotate/navigator/navigator.component.html +++ b/frontend/src/app/annotate/navigator/navigator.component.html @@ -44,7 +44,7 @@
@if (problemResponse) { - {{ problemResponse?.index ?? '-' }} + {{ problemResponse?.index ?? "-" }}
{{ problemResponse.total }} diff --git a/frontend/src/app/annotate/search/search.component.html b/frontend/src/app/annotate/search/search.component.html index 9cf6dd5..6435d4b 100644 --- a/frontend/src/app/annotate/search/search.component.html +++ b/frontend/src/app/annotate/search/search.component.html @@ -47,13 +47,17 @@
- + i18n-label + outline + fullWidth + />
diff --git a/frontend/src/app/annotate/search/search.component.ts b/frontend/src/app/annotate/search/search.component.ts index cc030e6..62f69ca 100644 --- a/frontend/src/app/annotate/search/search.component.ts +++ b/frontend/src/app/annotate/search/search.component.ts @@ -10,14 +10,15 @@ import { import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { faSearch, faTimes } from "@fortawesome/free-solid-svg-icons"; import { NgbDropdownModule } from "@ng-bootstrap/ng-bootstrap"; -import { BehaviorSubject, distinctUntilChanged, map } from "rxjs"; +import { BehaviorSubject, map } from "rxjs"; import { FilterSelectComponent, SelectOption, } from "./filter-select/filter-select.component"; import { datasetLabels, entailmentLabels } from "@/shared/displayTextMappings"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Params, Router } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; +import { IconButtonComponent } from "@/shared/icon-button/icon-button.component"; interface SearchParams { dataset: Dataset | null; @@ -39,6 +40,7 @@ type SearchParamsForm = { FontAwesomeModule, ReactiveFormsModule, FilterSelectComponent, + IconButtonComponent ], templateUrl: "./search.component.html", styleUrl: "./search.component.scss", @@ -122,10 +124,10 @@ export class SearchComponent { // Updates the route, which triggers a new query. private updateUrl(searchParams: SearchParams): void { - const url = this.router.createUrlTree([], { + this.router.navigate([], { relativeTo: this.route, - queryParams: searchParams - }).toString(); - this.router.navigateByUrl(url); + queryParams: searchParams, + queryParamsHandling: 'merge', + }); } } diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 5373800..245ee15 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,65 +1,66 @@ -import { Routes } from "@angular/router"; - -import { HomeComponent } from "./home/home.component"; -import { LoginComponent } from "./user/login/login.component"; -import { RegisterComponent } from "./user/register/register.component"; -import { VerifyEmailComponent } from "./user/verify-email/verify-email.component"; -import { PasswordForgottenComponent } from "./user/password-forgotten/password-forgotten.component"; -import { ResetPasswordComponent } from "./user/reset-password/reset-password.component"; -import { UserSettingsComponent } from "./user/user-settings/user-settings.component"; -import { LoggedOnGuard } from "./guards/logged-on.guard"; -import { AnnotateComponent } from "./annotate/annotate.component"; -import { AboutComponent } from "./about/about.component"; - -const routes: Routes = [ - { - path: "home", - component: HomeComponent, - }, - { - path: "login", - component: LoginComponent, - }, - { - path: "register", - component: RegisterComponent, - }, - { - path: "confirm-email/:key", - component: VerifyEmailComponent, - }, - { - path: "password-forgotten", - component: PasswordForgottenComponent, - }, - { - path: "reset-password/:uid/:token", - component: ResetPasswordComponent, - }, - { - path: "user-settings", - canActivate: [LoggedOnGuard], - component: UserSettingsComponent, - }, - { - path: "annotate/:problemId/edit", - canActivate: [LoggedOnGuard], - component: AnnotateComponent, - }, - { - path: "annotate/:problemId", - canActivate: [LoggedOnGuard], - component: AnnotateComponent, - }, - { - path: "about", - component: AboutComponent, - }, - { - path: "", - redirectTo: "/home", - pathMatch: "full", - }, -]; - -export { routes }; +import { Routes } from "@angular/router"; + +import { HomeComponent } from "./home/home.component"; +import { LoginComponent } from "./user/login/login.component"; +import { RegisterComponent } from "./user/register/register.component"; +import { VerifyEmailComponent } from "./user/verify-email/verify-email.component"; +import { PasswordForgottenComponent } from "./user/password-forgotten/password-forgotten.component"; +import { ResetPasswordComponent } from "./user/reset-password/reset-password.component"; +import { UserSettingsComponent } from "./user/user-settings/user-settings.component"; +import { LoggedOnGuard } from "./guards/logged-on.guard"; +import { AnnotateComponent } from "./annotate/annotate.component"; +import { AboutComponent } from "./about/about.component"; +import { CanEditOrAddGuard } from "./guards/can-edit-or-add.guard"; + +const routes: Routes = [ + { + path: "home", + component: HomeComponent, + }, + { + path: "login", + component: LoginComponent, + }, + { + path: "register", + component: RegisterComponent, + }, + { + path: "confirm-email/:key", + component: VerifyEmailComponent, + }, + { + path: "password-forgotten", + component: PasswordForgottenComponent, + }, + { + path: "reset-password/:uid/:token", + component: ResetPasswordComponent, + }, + { + path: "user-settings", + canActivate: [LoggedOnGuard], + component: UserSettingsComponent, + }, + { + path: "annotate/:problemId/edit", + canActivate: [LoggedOnGuard, CanEditOrAddGuard], + component: AnnotateComponent, + }, + { + path: "annotate/:problemId", + canActivate: [LoggedOnGuard, CanEditOrAddGuard], + component: AnnotateComponent, + }, + { + path: "about", + component: AboutComponent, + }, + { + path: "", + redirectTo: "/home", + pathMatch: "full", + } +]; + +export { routes }; diff --git a/frontend/src/app/guards/can-edit-or-add.guard.ts b/frontend/src/app/guards/can-edit-or-add.guard.ts new file mode 100644 index 0000000..af3a914 --- /dev/null +++ b/frontend/src/app/guards/can-edit-or-add.guard.ts @@ -0,0 +1,28 @@ +import { AuthService } from "@/services/auth.service"; +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn } from "@angular/router"; +import { filter, map, take } from "rxjs"; + +export const CanEditOrAddGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { + const authService = inject(AuthService); + + const editing = route.url.some(segment => segment.path === "edit"); + const adding = route.paramMap.get("problemId") === "new"; + + // No need to check permissions if just viewing. + if (!(editing || adding)) { + return true; + } + + return authService.currentUser$.pipe( + // Wait until we actually have a user (User | null). + filter(user => user !== undefined), + take(1), + map(user => { + if (!user) { + return false; + } + return editing ? user.canEditProblem : user.canCreateProblem; + }), + ); +}; diff --git a/frontend/src/app/menu/user-menu/user-menu.component.html b/frontend/src/app/menu/user-menu/user-menu.component.html index 01a0b84..8a8b1d1 100644 --- a/frontend/src/app/menu/user-menu/user-menu.component.html +++ b/frontend/src/app/menu/user-menu/user-menu.component.html @@ -1,63 +1,64 @@ - + diff --git a/frontend/src/app/menu/user-menu/user-menu.component.spec.ts b/frontend/src/app/menu/user-menu/user-menu.component.spec.ts index d2cb12e..1010491 100644 --- a/frontend/src/app/menu/user-menu/user-menu.component.spec.ts +++ b/frontend/src/app/menu/user-menu/user-menu.component.spec.ts @@ -1,109 +1,115 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { By } from "@angular/platform-browser"; -import { UserMenuComponent } from "./user-menu.component"; -import { AuthService } from "../../services/auth.service"; -import { - HttpTestingController, - provideHttpClientTesting, -} from "@angular/common/http/testing"; -import { UserResponse } from "../../user/models/user"; -import { provideRouter } from "@angular/router"; -import { - provideHttpClient, - withInterceptorsFromDi, -} from "@angular/common/http"; - -const fakeUserResponse: UserResponse = { - id: 1, - username: "frodo", - email: "frodo@shire.me", - first_name: "Frodo", - last_name: "Baggins", - is_staff: false, -}; - -const fakeAdminResponse: UserResponse = { - id: 1, - username: "gandalf", - email: "gandalf@istari.me", - first_name: "Gandalf", - last_name: "The Grey", - is_staff: true, -}; - -describe("UserMenuComponent", () => { - let component: UserMenuComponent; - let fixture: ComponentFixture; - let httpTestingController: HttpTestingController; - - const spinner = () => fixture.debugElement.query(By.css(".spinner-border")); - const signInLink = () => - fixture.debugElement.query(By.css('a[href="/login"]')); - const userDropdownTrigger = () => - fixture.debugElement.query(By.css("#userDropdown")); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [ - AuthService, - provideRouter([]), - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - ], - }); - httpTestingController = TestBed.inject(HttpTestingController); - fixture = TestBed.createComponent(UserMenuComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - afterEach(() => { - httpTestingController.verify(); - }); - - it("should create", () => { - httpTestingController.expectOne("/users/user/"); - expect(component).toBeTruthy(); - }); - - it("should show sign-in when not logged in", () => { - const req = httpTestingController.expectOne("/users/user/"); - req.flush(null); - fixture.detectChanges(); - - expect(spinner()).toBeFalsy(); - expect(signInLink()).toBeTruthy(); - expect(userDropdownTrigger()).toBeFalsy(); - }); - - it("should show a loading spinner", () => { - httpTestingController.expectOne("/users/user/"); - expect(spinner()).toBeTruthy(); - expect(signInLink()).toBeFalsy(); - expect(userDropdownTrigger()).toBeFalsy(); - }); - - it("should show an admin menu when user is a staff member", () => { - const req = httpTestingController.expectOne("/users/user/"); - req.flush(fakeAdminResponse); - fixture.detectChanges(); - - expect(spinner()).toBeFalsy(); - expect(signInLink()).toBeFalsy(); - expect(userDropdownTrigger()).toBeTruthy(); - expect(userDropdownTrigger().nativeElement.textContent).toContain( - "gandalf" - ); - }); - - it("should show a user menu when logged in", () => { - const req = httpTestingController.expectOne("/users/user/"); - req.flush(fakeUserResponse); - fixture.detectChanges(); - - expect(spinner()).toBeFalsy(); - expect(signInLink()).toBeFalsy(); - expect(userDropdownTrigger()).toBeTruthy(); - }); -}); +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { UserMenuComponent } from "./user-menu.component"; +import { AuthService } from "../../services/auth.service"; +import { + HttpTestingController, + provideHttpClientTesting, +} from "@angular/common/http/testing"; +import { UserResponse, UserRole } from "../../user/models/user"; +import { provideRouter } from "@angular/router"; +import { + provideHttpClient, + withInterceptorsFromDi, +} from "@angular/common/http"; + +const fakeUserResponse: UserResponse = { + id: 1, + username: "frodo", + email: "frodo@shire.me", + firstName: "Frodo", + lastName: "Baggins", + isStaff: false, + role: UserRole.VISITOR, + canCreateProblem: false, + canEditProblem: false, +}; + +const fakeAdminResponse: UserResponse = { + id: 1, + username: "gandalf", + email: "gandalf@istari.me", + firstName: "Gandalf", + lastName: "The Grey", + isStaff: true, + role: UserRole.MASTER_ANNOTATOR, + canCreateProblem: true, + canEditProblem: true, +}; + +describe("UserMenuComponent", () => { + let component: UserMenuComponent; + let fixture: ComponentFixture; + let httpTestingController: HttpTestingController; + + const spinner = () => fixture.debugElement.query(By.css(".spinner-border")); + const signInLink = () => + fixture.debugElement.query(By.css('a[href="/login"]')); + const userDropdownTrigger = () => + fixture.debugElement.query(By.css("#userDropdown")); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + AuthService, + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + httpTestingController = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(UserMenuComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it("should create", () => { + httpTestingController.expectOne("/users/user/"); + expect(component).toBeTruthy(); + }); + + it("should show sign-in when not logged in", () => { + const req = httpTestingController.expectOne("/users/user/"); + req.flush(null); + fixture.detectChanges(); + + expect(spinner()).toBeFalsy(); + expect(signInLink()).toBeTruthy(); + expect(userDropdownTrigger()).toBeFalsy(); + }); + + it("should show a loading spinner", () => { + httpTestingController.expectOne("/users/user/"); + expect(spinner()).toBeTruthy(); + expect(signInLink()).toBeFalsy(); + expect(userDropdownTrigger()).toBeFalsy(); + }); + + it("should show an admin menu when user is a staff member", () => { + const req = httpTestingController.expectOne("/users/user/"); + req.flush(fakeAdminResponse); + fixture.detectChanges(); + + expect(spinner()).toBeFalsy(); + expect(signInLink()).toBeFalsy(); + expect(userDropdownTrigger()).toBeTruthy(); + expect(userDropdownTrigger().nativeElement.textContent).toContain( + "gandalf" + ); + }); + + it("should show a user menu when logged in", () => { + const req = httpTestingController.expectOne("/users/user/"); + req.flush(fakeUserResponse); + fixture.detectChanges(); + + expect(spinner()).toBeFalsy(); + expect(signInLink()).toBeFalsy(); + expect(userDropdownTrigger()).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/menu/user-menu/user-menu.component.ts b/frontend/src/app/menu/user-menu/user-menu.component.ts index 6d148ae..2280a88 100644 --- a/frontend/src/app/menu/user-menu/user-menu.component.ts +++ b/frontend/src/app/menu/user-menu/user-menu.component.ts @@ -1,72 +1,72 @@ -import { Component, DestroyRef, OnInit } from "@angular/core"; -import { filter, map } from "rxjs"; -import { AuthService } from "../../services/auth.service"; -import { Router, RouterModule } from "@angular/router"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { CommonModule } from "@angular/common"; -import { faUser } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -import { ToastService } from "../../services/toast.service"; -import { NgbDropdownModule } from "@ng-bootstrap/ng-bootstrap"; - -@Component({ - selector: "la-user-menu", - templateUrl: "./user-menu.component.html", - styleUrls: ["./user-menu.component.scss"], - imports: [RouterModule, CommonModule, FontAwesomeModule, NgbDropdownModule], - standalone: true, -}) -export class UserMenuComponent implements OnInit { - public authLoading$ = this.authService.currentUser$.pipe( - map((user) => user === undefined) - ); - - public user$ = this.authService.currentUser$; - - public showSignIn$ = this.authService.currentUser$.pipe( - map((user) => user === null) - ); - - public logoutLoading$ = this.authService.logout.loading$; - - public currentPath$ = this.router.routerState.root.url.pipe( - map((url) => url.pop() ?? null), - filter((url) => url?.toString() !== "") - ); - - public faUser = faUser; - - constructor( - private authService: AuthService, - private toastService: ToastService, - private router: Router, - private destroyRef: DestroyRef - ) {} - - ngOnInit(): void { - this.authService.logout.error$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.toastService.show({ - header: $localize`Sign out failed`, - body: $localize`There was an error signing you out. Please try again.`, - type: "danger", - }); - }); - - this.authService.logout.success$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.toastService.show({ - header: $localize`Sign out successful`, - body: $localize`You have been successfully signed out.`, - type: "success", - }); - this.router.navigate(["/"]); - }); - } - - public logout(): void { - this.authService.logout.subject.next(); - } -} +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { filter, map } from "rxjs"; +import { AuthService } from "../../services/auth.service"; +import { Router, RouterModule } from "@angular/router"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { CommonModule } from "@angular/common"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { ToastService } from "../../services/toast.service"; +import { NgbDropdownModule } from "@ng-bootstrap/ng-bootstrap"; +import user2icon from "@/shared/user2icon"; + +@Component({ + selector: "la-user-menu", + templateUrl: "./user-menu.component.html", + styleUrls: ["./user-menu.component.scss"], + imports: [RouterModule, CommonModule, FontAwesomeModule, NgbDropdownModule], + standalone: true, +}) +export class UserMenuComponent implements OnInit { + public authLoading$ = this.authService.currentUser$.pipe( + map((user) => user === undefined) + ); + + public user$ = this.authService.currentUser$; + + public userIcon$ = this.user$.pipe(map(user2icon)); + + public showSignIn$ = this.authService.currentUser$.pipe( + map((user) => user === null) + ); + + public logoutLoading$ = this.authService.logout.loading$; + + public currentPath$ = this.router.routerState.root.url.pipe( + map((url) => url.pop() ?? null), + filter((url) => url?.toString() !== "") + ); + + constructor( + private authService: AuthService, + private toastService: ToastService, + private router: Router, + private destroyRef: DestroyRef + ) { } + + ngOnInit(): void { + this.authService.logout.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.show({ + header: $localize`Sign out failed`, + body: $localize`There was an error signing you out. Please try again.`, + type: "danger", + }); + }); + + this.authService.logout.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.show({ + header: $localize`Sign out successful`, + body: $localize`You have been successfully signed out.`, + type: "success", + }); + this.router.navigate(["/"]); + }); + } + + public logout(): void { + this.authService.logout.subject.next(); + } +} diff --git a/frontend/src/app/services/problem.service.spec.ts b/frontend/src/app/services/problem.service.spec.ts index f37e169..4dceabe 100644 --- a/frontend/src/app/services/problem.service.spec.ts +++ b/frontend/src/app/services/problem.service.spec.ts @@ -49,6 +49,7 @@ describe("ProblemService", () => { const mockResponse: ProblemResponse = { problem: { id: mockProblemId, + base: null, dataset: Dataset.SICK, premises: ["a"], hypothesis: "b", @@ -131,6 +132,7 @@ describe("ProblemService", () => { it("should PATCH the problem and return the response", (done) => { const problemToSave: ParseInput = { id: 1, + base: null, premises: ["a"], hypothesis: "b", kbItems: [], @@ -153,6 +155,7 @@ describe("ProblemService", () => { it("should handle errors during save", (done) => { const problemToSave: ParseInput = { id: 2, + base: null, premises: ["c"], hypothesis: "d", kbItems: [] diff --git a/frontend/src/app/services/problem.service.ts b/frontend/src/app/services/problem.service.ts index 2513f84..242cc1d 100644 --- a/frontend/src/app/services/problem.service.ts +++ b/frontend/src/app/services/problem.service.ts @@ -1,4 +1,5 @@ import { ParseInput } from "@/annotate/annotation-input/annotation-input.component"; +import extractBaseParam from "@/shared/extractBaseParam"; import { ProblemResponse, SaveProblemResponse, Dataset, EntailmentLabel } from "@/types"; import { HttpClient, HttpParams } from "@angular/common/http"; import { Injectable, inject } from "@angular/core"; @@ -35,11 +36,14 @@ export class ProblemService { if (!problemId) { return of(null); } - return problemId === "new" ? this.newProblem$() : this.existingProblem$(problemId, queryParams); + const baseParam = extractBaseParam(queryParams); + + return problemId === "new" ? this.newProblem$(baseParam) : this.existingProblem$(problemId, queryParams); }), shareReplay(1) ); + public problem$ = this.problemResponse$.pipe( map(response => response?.problem ?? null), shareReplay(1) @@ -74,8 +78,8 @@ export class ProblemService { }) ); - private newProblem$(): Observable { - return of({ + private newProblem$(baseParam: number | null): Observable { + const sharedProblemResponse: Omit = { index: null, first: null, last: null, @@ -83,8 +87,35 @@ export class ProblemService { previous: null, total: 0, error: null, + }; + + if (baseParam !== null) { + return this.existingProblem$(baseParam.toString()).pipe(map(response => { + const problem = response?.problem; + return { + ...sharedProblemResponse, + problem: { + id: null, + base: baseParam, + hypothesis: problem?.hypothesis ?? "", + dataset: Dataset.USER, + premises: problem?.premises ?? [], + entailmentLabel: EntailmentLabel.UNKNOWN, + extraData: null, + // KB items are not shared across problems. + kbItems: problem?.kbItems.map(kbItem => ({ + ...kbItem, + id: null, + })) ?? [] + } + }; + })); + } + return of({ + ...sharedProblemResponse, problem: { id: null, + base: baseParam, hypothesis: "", dataset: Dataset.USER, premises: [], diff --git a/frontend/src/app/shared/areParamsEqual.ts b/frontend/src/app/shared/areParamsEqual.ts new file mode 100644 index 0000000..39ce915 --- /dev/null +++ b/frontend/src/app/shared/areParamsEqual.ts @@ -0,0 +1,30 @@ +import { ParamMap } from "@angular/router"; + +/** + * Compares two sets of route parameters and query parameters and an 'edit' + * flag to determine if they are equal. + * + * @param oldParams - A tuple containing the previous route parameters, query + * parameters, and edit flag. + * @param newParams - A tuple containing the new route parameters, query + * parameters, and edit flag. + * @returns `true` if all parameters and query parameters are equal and the + * edit flags match, otherwise `false`. + */ +export default function areParamsEqual( + [oldParams, oldQueryParams, oldEditParam]: [ParamMap, ParamMap, boolean], + [newParams, newQueryParams, newEditParam]: [ParamMap, ParamMap, boolean] +): boolean { + const compareMaps = (map1: ParamMap, map2: ParamMap) => { + if (map1.keys.length !== map2.keys.length) { + return false; + } + return map1.keys.every((key: string) => map1.get(key) === map2.get(key)); + }; + + return ( + compareMaps(oldParams, newParams) && + compareMaps(oldQueryParams, newQueryParams) && + oldEditParam === newEditParam + ); +} diff --git a/frontend/src/app/shared/extractBaseParam.ts b/frontend/src/app/shared/extractBaseParam.ts new file mode 100644 index 0000000..64b0fb1 --- /dev/null +++ b/frontend/src/app/shared/extractBaseParam.ts @@ -0,0 +1,14 @@ +import { ParamMap } from "@angular/router"; + +/** + * Extracts the base parameter from query parameters and returns it as a number. + * @param queryParams - The query parameters map to extract the base value from. + * @returns The base value as a number, or null if the base parameter is not present. + */ +export default function extractBaseParam(queryParams: ParamMap): number | null { + const baseStr = queryParams.get("base"); + if (!baseStr) { + return null; + } + return parseInt(baseStr, 10); +} diff --git a/frontend/src/app/shared/icon-button/icon-button.component.html b/frontend/src/app/shared/icon-button/icon-button.component.html new file mode 100644 index 0000000..0648430 --- /dev/null +++ b/frontend/src/app/shared/icon-button/icon-button.component.html @@ -0,0 +1,31 @@ +@let iconValue = icon(); +@let position = iconPosition(); +@let visibleLabel = label() && !visuallyHiddenLabel(); + +@if (titleId()) { +{{ buttonTitle() }} +} diff --git a/frontend/src/app/shared/icon-button/icon-button.component.scss b/frontend/src/app/shared/icon-button/icon-button.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/shared/icon-button/icon-button.component.spec.ts b/frontend/src/app/shared/icon-button/icon-button.component.spec.ts new file mode 100644 index 0000000..d2a2c30 --- /dev/null +++ b/frontend/src/app/shared/icon-button/icon-button.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IconButtonComponent } from './icon-button.component'; + +describe('IconButtonComponent', () => { + let component: IconButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IconButtonComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(IconButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/icon-button/icon-button.component.ts b/frontend/src/app/shared/icon-button/icon-button.component.ts new file mode 100644 index 0000000..dd87301 --- /dev/null +++ b/frontend/src/app/shared/icon-button/icon-button.component.ts @@ -0,0 +1,53 @@ +import { booleanAttribute, Component, computed, input } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconProp } from '@fortawesome/angular-fontawesome/types'; + +@Component({ + selector: 'la-icon-button', + imports: [FontAwesomeModule], + templateUrl: './icon-button.component.html', + styleUrl: './icon-button.component.scss' +}) +export class IconButtonComponent { + public icon = input(null); + public label = input(null); + public buttonTitle = input(null); + public iconPosition = input<'left' | 'right'>('left'); + public size = input<'sm' | 'md' | 'lg'>('md'); + public buttonType = input<'button' | 'submit' | 'reset'>('button'); + public buttonStyle = input<"primary" | "secondary" | "success" | "danger">("primary"); + public disabled = input(false, { transform: booleanAttribute }); + public outline = input(false, { transform: booleanAttribute }); + public fullWidth = input(false, { transform: booleanAttribute }); + public visuallyHiddenLabel = input(false, { transform: booleanAttribute }); + + public buttonClasses = computed(() => { + const classes = ["btn", "d-flex", "align-items-center", "justify-content-center"]; + if (this.outline()) { + classes.push(`btn-outline-${this.buttonStyle()}`); + } else { + classes.push(`btn-${this.buttonStyle()}`); + } + + if (this.size() === 'sm') { + classes.push('btn-sm'); + } else if (this.size() === 'lg') { + classes.push('btn-lg'); + } + + if (this.fullWidth()) { + classes.push('w-100'); + } + + return classes.join(" "); + }); + + public titleId = computed(() => { + const title = this.buttonTitle(); + if (!title) { + return null; + } + return title.replace(/\s+/g, '-').toLowerCase(); + }); + +} diff --git a/frontend/src/app/shared/user2icon.ts b/frontend/src/app/shared/user2icon.ts new file mode 100644 index 0000000..7cbf842 --- /dev/null +++ b/frontend/src/app/shared/user2icon.ts @@ -0,0 +1,16 @@ +import { User, UserRole } from "@/user/models/user"; +import { IconDefinition } from "@fortawesome/angular-fontawesome"; +import { faUser, faUserAstronaut, faUserGraduate, faUserTag } from "@fortawesome/free-solid-svg-icons"; + +const DEFAULT_USER_ICON = faUser; + +const ROLE_ICONS: Record = { + [UserRole.SUPERUSER]: faUserAstronaut, + [UserRole.ANNOTATOR]: faUserTag, + [UserRole.MASTER_ANNOTATOR]: faUserGraduate, + [UserRole.VISITOR]: faUser, +}; + +export default function user2icon(user: User | null | undefined): IconDefinition { + return user ? ROLE_ICONS[user.role] : DEFAULT_USER_ICON; +} diff --git a/frontend/src/app/types.ts b/frontend/src/app/types.ts index 11fbc59..2f5ef07 100644 --- a/frontend/src/app/types.ts +++ b/frontend/src/app/types.ts @@ -31,7 +31,7 @@ export enum KnowledgeBaseRelationship { } interface KnowledgeBaseItem { - id: number; + id: number | null; entity1: string; relationship: KnowledgeBaseRelationship; entity2: string; @@ -39,6 +39,7 @@ interface KnowledgeBaseItem { interface ProblemBase { id: number | null; + base: number | null; premises: string[]; hypothesis: string | null; entailmentLabel: EntailmentLabel; diff --git a/frontend/src/app/user/models/user.ts b/frontend/src/app/user/models/user.ts index 567eac2..58dbae0 100644 --- a/frontend/src/app/user/models/user.ts +++ b/frontend/src/app/user/models/user.ts @@ -1,59 +1,73 @@ -export interface UserResponse { - id: number; - username: string; - email: string; - first_name: string; - last_name: string; - is_staff: boolean; -} - -export class User { - constructor( - public id: number, - public username: string, - public email: string, - public firstName: string, - public lastName: string, - public isStaff: boolean - ) {} -} - -export interface UserRegistration { - username: string; - email: string; - password1: string; - password2: string; -} - -export interface UserLogin { - username: string; - password: string; -} - -export interface ResetPassword { - uid: string; - token: string; - new_password1: string; - new_password2: string; -} - -export interface PasswordForgotten { - email: string; -} - -export interface KeyInfoResult { - username: string; - email: string; -} - -export interface KeyInfo { - key: string; -} - -// Dj-rest-auth does not let you update your email address, but we need it to request the password reset form. -export type UserSettings = Pick< - User, - "id" | "email" | "firstName" | "lastName" -> & { - username?: string; -}; +export interface UserResponse { + id: number; + username: string; + email: string; + firstName: string; + lastName: string; + isStaff: boolean; + role: string; + canEditProblem: boolean; + canCreateProblem: boolean; +} + +// Corresponds to frontend user type. +export enum UserRole { + SUPERUSER = "superuser", + ANNOTATOR = "annotator", + MASTER_ANNOTATOR = "master_annotator", + VISITOR = "visitor", +} + +export class User { + constructor( + public id: number, + public username: string, + public email: string, + public firstName: string, + public lastName: string, + public isStaff: boolean, + public role: UserRole, + public canEditProblem: boolean, + public canCreateProblem: boolean, + ) { } +} + +export interface UserRegistration { + username: string; + email: string; + password1: string; + password2: string; +} + +export interface UserLogin { + username: string; + password: string; +} + +export interface ResetPassword { + uid: string; + token: string; + new_password1: string; + new_password2: string; +} + +export interface PasswordForgotten { + email: string; +} + +export interface KeyInfoResult { + username: string; + email: string; +} + +export interface KeyInfo { + key: string; +} + +// Dj-rest-auth does not let you update your email address, but we need it to request the password reset form. +export type UserSettings = Pick< + User, + "id" | "email" | "firstName" | "lastName" +> & { + username?: string; +}; diff --git a/frontend/src/app/user/user-settings/user-settings.component.spec.ts b/frontend/src/app/user/user-settings/user-settings.component.spec.ts index 6e6063f..e153d92 100644 --- a/frontend/src/app/user/user-settings/user-settings.component.spec.ts +++ b/frontend/src/app/user/user-settings/user-settings.component.spec.ts @@ -1,199 +1,202 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { UserSettingsComponent } from "./user-settings.component"; -import { ToastService } from "../../services/toast.service"; -import { AuthService } from "../../services/auth.service"; -import { - HttpTestingController, - provideHttpClientTesting, -} from "@angular/common/http/testing"; -import { User } from "../models/user"; -import { Observable, of } from "rxjs"; -import { Injectable } from "@angular/core"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { - provideHttpClient, - withInterceptorsFromDi, -} from "@angular/common/http"; - -const fakeUser: User = { - id: 1, - email: "frodo@shire.me", - firstName: "Frodo", - lastName: "Baggins", - username: "frodo", - isStaff: false, -}; - -@Injectable({ providedIn: "root" }) -class AuthServiceMock extends AuthService { - public override currentUser$: Observable = - of(fakeUser); -} - -describe("UserSettingsComponent", () => { - let component: UserSettingsComponent; - let fixture: ComponentFixture; - let toastService: ToastService; - let httpTestingController: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - { - provide: AuthService, - useClass: AuthServiceMock, - }, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - ], - }); - toastService = TestBed.inject(ToastService); - httpTestingController = TestBed.inject(HttpTestingController); - fixture = TestBed.createComponent(UserSettingsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - // Initial request to get the user data in AuthService - httpTestingController.expectOne("/users/user/").flush(fakeUser); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - it("should patch the form with existing user data during OnInit", () => { - expect(component.form.value).toEqual({ - id: 1, - email: "frodo@shire.me", - username: "frodo", - firstName: "Frodo", - lastName: "Baggins", - }); - }); - - it("should check missing input", () => { - component.form.controls.username?.setValue(""); - component.submit(); - - httpTestingController.expectNone("/users/user/"); - - expect(component.form.controls.username?.invalid).toBeTrue(); - expect(component.form.controls.username?.errors).toEqual({ - required: true, - }); - }); - - it("should handle an invalid username", () => { - component.form.controls.username?.setValue("fb"); - component.submit(); - - httpTestingController.expectNone("/users/user/"); - - expect(component.form.controls.username?.invalid).toBeTrue(); - expect(component.form.controls.username?.errors).toEqual({ - minlength: { requiredLength: 3, actualLength: 2 }, - }); - }); - - it("should handle a username that is already taken", () => { - component.form.controls.username?.setValue("frodo"); - component.submit(); - - const req = httpTestingController.expectOne("/users/user/"); - req.flush( - { - username: ["A user with that username already exists."], - }, - { - status: 400, - statusText: "Bad request", - } - ); - - expect(component.form.invalid).toBeTrue(); - expect(component.form.controls.username?.errors).toEqual({ - invalid: "A user with that username already exists.", - }); - }); - - it("should handle a password reset request", () => { - const loading = TestBed.runInInjectionContext(() => - toSignal(component.requestResetLoading$) - ); - - component.requestPasswordReset(); - expect(loading()).toBeTrue(); - - const req = httpTestingController.expectOne("/users/password/reset/"); - req.flush({ - detail: "Password reset e-mail has been sent.", - }); - - expect(loading()).toBeFalse(); - expect(toastService.toasts.length).toBe(1); - }); - - it("should handle a user settings update", () => { - const loading = TestBed.runInInjectionContext(() => - toSignal(component.updateSettingsLoading$) - ); - - component.form.controls.firstName.setValue("Bilbo"); - - component.submit(); - expect(loading()).toBeTrue(); - - const req = httpTestingController.expectOne("/users/user/"); - req.flush({ - id: 1, - username: "frodo", - email: "frodo@shire.me", - first_name: "Bilbo", - last_name: "Baggins", - is_staff: false, - }); - - expect(loading()).toBeFalse(); - expect(toastService.toasts.length).toBe(1); - expect(component.form.controls.firstName.value).toBe("Bilbo"); - }); - - it("should handle a username change", () => { - const loading = TestBed.runInInjectionContext(() => - toSignal(component.updateSettingsLoading$) - ); - - component.form.controls.username?.setValue("Samwise"); - - component.submit(); - expect(loading()).toBeTrue(); - - const req = httpTestingController.expectOne("/users/user/"); - req.flush({ - id: 1, - username: "Samwise", - email: "frodo@shire.me", - first_name: "Frodo", - last_name: "Baggins", - is_staff: false, - }); - - expect(loading()).toBeFalse(); - expect(toastService.toasts.length).toBe(1); - expect(component.form.controls.username?.value).toBe("Samwise"); - }); - - it("should remove the username from the input if it's the same as the current username", () => { - const loading = TestBed.runInInjectionContext(() => - toSignal(component.updateSettingsLoading$) - ); - - component.submit(); - expect(loading()).toBeTrue(); - - const req = httpTestingController.expectOne("/users/user/").request; - expect(req.method).toBe("PATCH"); - expect(req.body).not.toContain("username"); - }); -}); +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { UserSettingsComponent } from "./user-settings.component"; +import { ToastService } from "../../services/toast.service"; +import { AuthService } from "../../services/auth.service"; +import { + HttpTestingController, + provideHttpClientTesting, +} from "@angular/common/http/testing"; +import { User, UserRole } from "../models/user"; +import { Observable, of } from "rxjs"; +import { Injectable } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { + provideHttpClient, + withInterceptorsFromDi, +} from "@angular/common/http"; + +const fakeUser: User = { + id: 1, + email: "frodo@shire.me", + firstName: "Frodo", + lastName: "Baggins", + username: "frodo", + isStaff: false, + role: UserRole.VISITOR, + canCreateProblem: false, + canEditProblem: false, +}; + +@Injectable({ providedIn: "root" }) +class AuthServiceMock extends AuthService { + public override currentUser$: Observable = + of(fakeUser); +} + +describe("UserSettingsComponent", () => { + let component: UserSettingsComponent; + let fixture: ComponentFixture; + let toastService: ToastService; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: AuthService, + useClass: AuthServiceMock, + }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + toastService = TestBed.inject(ToastService); + httpTestingController = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(UserSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + // Initial request to get the user data in AuthService + httpTestingController.expectOne("/users/user/").flush(fakeUser); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should patch the form with existing user data during OnInit", () => { + expect(component.form.value).toEqual({ + id: 1, + email: "frodo@shire.me", + username: "frodo", + firstName: "Frodo", + lastName: "Baggins", + }); + }); + + it("should check missing input", () => { + component.form.controls.username?.setValue(""); + component.submit(); + + httpTestingController.expectNone("/users/user/"); + + expect(component.form.controls.username?.invalid).toBeTrue(); + expect(component.form.controls.username?.errors).toEqual({ + required: true, + }); + }); + + it("should handle an invalid username", () => { + component.form.controls.username?.setValue("fb"); + component.submit(); + + httpTestingController.expectNone("/users/user/"); + + expect(component.form.controls.username?.invalid).toBeTrue(); + expect(component.form.controls.username?.errors).toEqual({ + minlength: { requiredLength: 3, actualLength: 2 }, + }); + }); + + it("should handle a username that is already taken", () => { + component.form.controls.username?.setValue("frodo"); + component.submit(); + + const req = httpTestingController.expectOne("/users/user/"); + req.flush( + { + username: ["A user with that username already exists."], + }, + { + status: 400, + statusText: "Bad request", + } + ); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.controls.username?.errors).toEqual({ + invalid: "A user with that username already exists.", + }); + }); + + it("should handle a password reset request", () => { + const loading = TestBed.runInInjectionContext(() => + toSignal(component.requestResetLoading$) + ); + + component.requestPasswordReset(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne("/users/password/reset/"); + req.flush({ + detail: "Password reset e-mail has been sent.", + }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + }); + + it("should handle a user settings update", () => { + const loading = TestBed.runInInjectionContext(() => + toSignal(component.updateSettingsLoading$) + ); + + component.form.controls.firstName.setValue("Bilbo"); + + component.submit(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne("/users/user/"); + req.flush({ + id: 1, + username: "frodo", + email: "frodo@shire.me", + first_name: "Bilbo", + last_name: "Baggins", + is_staff: false, + }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + expect(component.form.controls.firstName.value).toBe("Bilbo"); + }); + + it("should handle a username change", () => { + const loading = TestBed.runInInjectionContext(() => + toSignal(component.updateSettingsLoading$) + ); + + component.form.controls.username?.setValue("Samwise"); + + component.submit(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne("/users/user/"); + req.flush({ + id: 1, + username: "Samwise", + email: "frodo@shire.me", + first_name: "Frodo", + last_name: "Baggins", + is_staff: false, + }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + expect(component.form.controls.username?.value).toBe("Samwise"); + }); + + it("should remove the username from the input if it's the same as the current username", () => { + const loading = TestBed.runInInjectionContext(() => + toSignal(component.updateSettingsLoading$) + ); + + component.submit(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne("/users/user/").request; + expect(req.method).toBe("PATCH"); + expect(req.body).not.toContain("username"); + }); +}); diff --git a/frontend/src/app/user/utils.spec.ts b/frontend/src/app/user/utils.spec.ts index 34649e6..816f0ff 100644 --- a/frontend/src/app/user/utils.spec.ts +++ b/frontend/src/app/user/utils.spec.ts @@ -1,152 +1,158 @@ -import { TestBed } from "@angular/core/testing"; -import { FormGroup, FormControl, Validators } from "@angular/forms"; -import { User, UserResponse } from "./models/user"; -import { - parseUserData, - encodeUserData, - setErrors, - controlErrorMessages$, - formErrorMessages$, - updateFormValidity, -} from "./utils"; -import { toSignal } from "@angular/core/rxjs-interop"; - -describe("User utils", () => { - let form: FormGroup; - beforeEach(() => { - form = new FormGroup({ - username: new FormControl("", { - validators: [Validators.required], - }), - email: new FormControl("", { - validators: [Validators.required, Validators.email], - }), - }); - }); - - describe("parseUserData", () => { - it("should return null if result is null", () => { - const result: UserResponse | null = null; - const user = parseUserData(result); - expect(user).toBeNull(); - }); - - it("should return a User object if result is not null", () => { - const result: UserResponse = { - id: 1, - username: "testuser", - email: "test@example.com", - first_name: "Test", - last_name: "User", - is_staff: true, - }; - const user = parseUserData(result); - expect(user).toBeInstanceOf(User); - expect(user?.id).toBe(1); - expect(user?.username).toBe("testuser"); - expect(user?.email).toBe("test@example.com"); - expect(user?.firstName).toBe("Test"); - expect(user?.lastName).toBe("User"); - expect(user?.isStaff).toBe(true); - }); - }); - - describe("encodeUserData", () => { - it("should encode partial User data to UserResponse object", () => { - const data: Partial = { - id: 1, - username: "testuser", - email: "test@example.com", - firstName: "Test", - lastName: "User", - isStaff: true, - }; - const encoded = encodeUserData(data); - expect(encoded).toEqual({ - id: 1, - username: "testuser", - email: "test@example.com", - first_name: "Test", - last_name: "User", - is_staff: true, - }); - }); - }); - - describe("setErrors", () => { - it("should set errors on associated controls", () => { - const errorObject = { - username: "Username is required.", - email: "Email is invalid.", - }; - setErrors(errorObject, form); - expect(form.get("username")?.errors).toEqual({ - invalid: "Username is required.", - }); - expect(form.get("email")?.errors).toEqual({ - invalid: "Email is invalid.", - }); - }); - - it("should set errors on the form itself for non-associated controls", () => { - const errorObject = { - random: "Passwords must be identical.", - }; - setErrors(errorObject, form); - expect(form.errors).toEqual({ - invalid: "Passwords must be identical.", - }); - }); - }); - - describe("controlErrorMessages$", () => { - it("should return an Observable of error messages for a specific control", () => { - const usernameMessages = TestBed.runInInjectionContext(() => - toSignal(controlErrorMessages$(form, "username")), - ); - - form.get("username")?.updateValueAndValidity(); - - expect(usernameMessages()).toEqual(["Username is required."]); - }); - - it("should use errors based on a provided lookup name", () => { - const usernameMessages = TestBed.runInInjectionContext(() => - toSignal(controlErrorMessages$(form, "username", "email")), - ); - - form.get("username")?.updateValueAndValidity(); - - expect(usernameMessages()).toEqual(["Email is required."]); - }); - }); - - describe("formErrorMessages$", () => { - it("should return an Observable of error messages for the form", () => { - const formMessages = TestBed.runInInjectionContext(() => - toSignal(formErrorMessages$(form)), - ); - - form.updateValueAndValidity(); - - expect(formMessages()?.length).toBe(0); - - form.setErrors({ random: "This is a random error." }); - - expect(formMessages()).toEqual(["This is a random error."]); - }); - }); - - describe("updateFormValidity", () => { - it("should update the validity of all controls in the form", () => { - form.controls["username"].setErrors({ - invalid: "Username is required.", - }); - form.controls["email"].setErrors({ invalid: "Email is invalid." }); - updateFormValidity(form); - expect(form.controls["username"].valid).toBe(false); - expect(form.controls["email"].valid).toBe(false); - expect(form.valid).toBe(false); - }); - }); -}); +import { TestBed } from "@angular/core/testing"; +import { FormGroup, FormControl, Validators } from "@angular/forms"; +import { User, UserResponse, UserRole } from "./models/user"; +import { + parseUserData, + encodeUserData, + setErrors, + controlErrorMessages$, + formErrorMessages$, + updateFormValidity, +} from "./utils"; +import { toSignal } from "@angular/core/rxjs-interop"; + +describe("User utils", () => { + let form: FormGroup; + beforeEach(() => { + form = new FormGroup({ + username: new FormControl("", { + validators: [Validators.required], + }), + email: new FormControl("", { + validators: [Validators.required, Validators.email], + }), + }); + }); + + describe("parseUserData", () => { + it("should return null if result is null", () => { + const result: UserResponse | null = null; + const user = parseUserData(result); + expect(user).toBeNull(); + }); + + it("should return a User object if result is not null", () => { + const result: UserResponse = { + id: 1, + username: "testuser", + email: "test@example.com", + firstName: "Test", + lastName: "User", + isStaff: true, + role: "visitor", + canCreateProblem: false, + canEditProblem: false, + }; + const user = parseUserData(result); + expect(user).toBeInstanceOf(User); + expect(user?.id).toBe(1); + expect(user?.username).toBe("testuser"); + expect(user?.email).toBe("test@example.com"); + expect(user?.firstName).toBe("Test"); + expect(user?.lastName).toBe("User"); + expect(user?.isStaff).toBe(true); + expect(user?.role).toBe(UserRole.VISITOR); + expect(user?.canEditProblem).toBe(false); + expect(user?.canCreateProblem).toBe(false); + }); + }); + + describe("encodeUserData", () => { + it("should encode partial User data to UserResponse object", () => { + const data: Partial = { + id: 1, + username: "testuser", + email: "test@example.com", + firstName: "Test", + lastName: "User", + isStaff: true, + }; + const encoded = encodeUserData(data); + expect(encoded).toEqual({ + id: 1, + username: "testuser", + email: "test@example.com", + firstName: "Test", + lastName: "User", + isStaff: true, + }); + }); + }); + + describe("setErrors", () => { + it("should set errors on associated controls", () => { + const errorObject = { + username: "Username is required.", + email: "Email is invalid.", + }; + setErrors(errorObject, form); + expect(form.get("username")?.errors).toEqual({ + invalid: "Username is required.", + }); + expect(form.get("email")?.errors).toEqual({ + invalid: "Email is invalid.", + }); + }); + + it("should set errors on the form itself for non-associated controls", () => { + const errorObject = { + random: "Passwords must be identical.", + }; + setErrors(errorObject, form); + expect(form.errors).toEqual({ + invalid: "Passwords must be identical.", + }); + }); + }); + + describe("controlErrorMessages$", () => { + it("should return an Observable of error messages for a specific control", () => { + const usernameMessages = TestBed.runInInjectionContext(() => + toSignal(controlErrorMessages$(form, "username")), + ); + + form.get("username")?.updateValueAndValidity(); + + expect(usernameMessages()).toEqual(["Username is required."]); + }); + + it("should use errors based on a provided lookup name", () => { + const usernameMessages = TestBed.runInInjectionContext(() => + toSignal(controlErrorMessages$(form, "username", "email")), + ); + + form.get("username")?.updateValueAndValidity(); + + expect(usernameMessages()).toEqual(["Email is required."]); + }); + }); + + describe("formErrorMessages$", () => { + it("should return an Observable of error messages for the form", () => { + const formMessages = TestBed.runInInjectionContext(() => + toSignal(formErrorMessages$(form)), + ); + + form.updateValueAndValidity(); + + expect(formMessages()?.length).toBe(0); + + form.setErrors({ random: "This is a random error." }); + + expect(formMessages()).toEqual(["This is a random error."]); + }); + }); + + describe("updateFormValidity", () => { + it("should update the validity of all controls in the form", () => { + form.controls["username"].setErrors({ + invalid: "Username is required.", + }); + form.controls["email"].setErrors({ invalid: "Email is invalid." }); + updateFormValidity(form); + expect(form.controls["username"].valid).toBe(false); + expect(form.controls["email"].valid).toBe(false); + expect(form.valid).toBe(false); + }); + }); +}); diff --git a/frontend/src/app/user/utils.ts b/frontend/src/app/user/utils.ts index 0df1d96..bf43f57 100644 --- a/frontend/src/app/user/utils.ts +++ b/frontend/src/app/user/utils.ts @@ -1,178 +1,190 @@ -import { AbstractControl, FormGroup } from "@angular/forms"; -import { User, UserResponse } from "./models/user"; -import { Observable, map } from "rxjs"; -import { RequestError } from "./Request"; - -/** - * Transforms backend user response to User object - * - * @param result User response data - * @returns User object - */ -export const parseUserData = (result: UserResponse | null): User | null => { - if (!result) { - return null; - } - return new User( - result.id, - result.username, - result.email, - result.first_name, - result.last_name, - result.is_staff - ); -}; - -/** - * Transfroms User data to backend UserResponse object - * - * Because this is used for patching, the data can be partial - * - * @param data (partial) User object - * @returns UserResponse object - */ -export const encodeUserData = (data: Partial): Partial => { - const encoded: Partial = { - id: data.id, - username: data.username, - email: data.email, - first_name: data.firstName, - last_name: data.lastName, - is_staff: data.isStaff, - }; - // Remove undefined values from object. - return Object.fromEntries( - Object.entries(encoded).filter(([_key, value]) => value !== undefined) - ); -}; - -/** - * Interprets backend validation errors and adds errors to their associated controls. - * - * Others are not tied to specific controls. These are added to the form itself with a generic 'invalid' key. - * - * @param errorObject - The error object containing the control names as keys and the corresponding error messages as values. - * @param form - The form to which the errors should be added. - */ -export function setErrors( - errorObject: RequestError["error"], - form: FormGroup -): void { - for (const errorKey in errorObject) { - const control = form.get(errorKey); - const error = errorObject[errorKey]; - const errorMessage = Array.isArray(error) ? error.join("; ") : error; - if (control) { - control.setErrors({ invalid: errorMessage }); - } else { - form.setErrors({ invalid: errorMessage }); - } - } -} - -export const ERROR_MAP: Record> = { - username: { - required: "Username is required.", - minlength: "Username must be at least 3 characters long.", - maxlength: "Username must be at most 150 characters long.", - }, - email: { - required: "Email is required.", - email: "Email is invalid.", - }, - password: { - required: "Password is required.", - minlength: "Password must be at least 8 characters long.", - }, - token: { - invalid: "The URL is invalid. Please request a new one.", - }, - uid: { - invalid: "The URL is invalid. Please request a new one.", - }, - form: { - passwords: "Passwords must be identical.", - }, - firstName: { - required: "First name is required.", - }, - lastName: { - required: "Last name is required.", - }, -}; - -/** - * Watches a FormControl and returns an Observable that yields an array of error messages. - * - * Uses the optional parameter `lookup` to determine which error messages from `ERROR_MAP` to use. If no `lookup` is - * provided, `controlName` is used. If no error messages are found using either `lookup` or `controlName`, `ERROR_MAP['form']` is used. - * - * @param form - The FormGroup instance. - * @param controlName - The name of the form control. - * @param lookup - The key to use in the error map. Defaults to the control name. - * @returns An Observable that emits an array of error messages every time the control's status changes. - */ -export function controlErrorMessages$< - F extends FormGroup, - K extends string & keyof F["controls"] ->(form: F, controlName: K, lookup?: string): Observable { - const control = form.controls[controlName]; - // Get a subset of error messages based on the lookup key, if provided, or the control name. - const messagesForControl = lookup - ? ERROR_MAP[lookup] - : ERROR_MAP[controlName] ?? ERROR_MAP["form"]; - return control.statusChanges.pipe( - map(() => mapErrorsToMessages(control, messagesForControl)) - ); -} - -/** - * Watches a FormGroup and turns its errors into an array of string messages. - * - * Uses the optional parameter `lookup` to determine which error messages from `ERROR_MAP` to use. If no `lookup` is - * provided, or if no error messages are found, `ERROR_MAP['form']` is used. - * - * @param form The form group to check for errors. - * @param lookup Optional parameter to specify a specific error lookup key. - * @returns An observable that emits an array of error messages. - */ -export function formErrorMessages$( - form: F, - lookup?: string -): Observable { - const messagesForForm = lookup ? ERROR_MAP[lookup] : ERROR_MAP["form"]; - return form.statusChanges.pipe( - map(() => mapErrorsToMessages(form, messagesForForm)) - ); -} - -/** - * Maps control errors to error messages using the provided error map. - * - * If no message is specified for an error key, the error value itself is used as the message. - * - * @param control - The control containing the errors. - * @param errorMap - The map of error keys to error messages. - * @returns An array of error messages. - */ -function mapErrorsToMessages( - control: AbstractControl, - errorMap: Record -): string[] { - const errors = control.errors ?? {}; - return Object.keys(errors).map((errorKey) => - errorKey in errorMap ? errorMap[errorKey] : errors[errorKey] - ); -} - -/** - * Updates the validity of all controls in the given form and the form itself. - * - * @param form - The form group to update. - */ -export function updateFormValidity(form: FormGroup): void { - Object.values(form.controls).forEach((control) => { - control.updateValueAndValidity(); - }); - form.updateValueAndValidity(); -} +import { AbstractControl, FormGroup } from "@angular/forms"; +import { User, UserResponse, UserRole } from "./models/user"; +import { Observable, map } from "rxjs"; +import { RequestError } from "./Request"; + +function isUserRole(value: string): value is UserRole { + return Object.values(UserRole).includes(value as UserRole); +}; + +/** + * Transforms backend user response to User object + * + * @param result User response data + * @returns User object + */ +export const parseUserData = (result: UserResponse | null): User | null => { + if (!result) { + return null; + } + + if (!isUserRole(result.role)) { + throw new Error(`Unknown user role: ${result.role}`); + } + + return new User( + result.id, + result.username, + result.email, + result.firstName, + result.lastName, + result.isStaff, + result.role, + result.canEditProblem, + result.canCreateProblem, + ); +}; + +/** + * Transfroms User data to backend UserResponse object + * + * Because this is used for patching, the data can be partial + * + * @param data (partial) User object + * @returns UserResponse object + */ +export const encodeUserData = (data: Partial): Partial => { + const encoded: Partial = { + id: data.id, + username: data.username, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + isStaff: data.isStaff, + }; + // Remove undefined values from object. + return Object.fromEntries( + Object.entries(encoded).filter(([_key, value]) => value !== undefined) + ); +}; + +/** + * Interprets backend validation errors and adds errors to their associated controls. + * + * Others are not tied to specific controls. These are added to the form itself with a generic 'invalid' key. + * + * @param errorObject - The error object containing the control names as keys and the corresponding error messages as values. + * @param form - The form to which the errors should be added. + */ +export function setErrors( + errorObject: RequestError["error"], + form: FormGroup +): void { + for (const errorKey in errorObject) { + const control = form.get(errorKey); + const error = errorObject[errorKey]; + const errorMessage = Array.isArray(error) ? error.join("; ") : error; + if (control) { + control.setErrors({ invalid: errorMessage }); + } else { + form.setErrors({ invalid: errorMessage }); + } + } +} + +export const ERROR_MAP: Record> = { + username: { + required: "Username is required.", + minlength: "Username must be at least 3 characters long.", + maxlength: "Username must be at most 150 characters long.", + }, + email: { + required: "Email is required.", + email: "Email is invalid.", + }, + password: { + required: "Password is required.", + minlength: "Password must be at least 8 characters long.", + }, + token: { + invalid: "The URL is invalid. Please request a new one.", + }, + uid: { + invalid: "The URL is invalid. Please request a new one.", + }, + form: { + passwords: "Passwords must be identical.", + }, + firstName: { + required: "First name is required.", + }, + lastName: { + required: "Last name is required.", + }, +}; + +/** + * Watches a FormControl and returns an Observable that yields an array of error messages. + * + * Uses the optional parameter `lookup` to determine which error messages from `ERROR_MAP` to use. If no `lookup` is + * provided, `controlName` is used. If no error messages are found using either `lookup` or `controlName`, `ERROR_MAP['form']` is used. + * + * @param form - The FormGroup instance. + * @param controlName - The name of the form control. + * @param lookup - The key to use in the error map. Defaults to the control name. + * @returns An Observable that emits an array of error messages every time the control's status changes. + */ +export function controlErrorMessages$< + F extends FormGroup, + K extends string & keyof F["controls"] +>(form: F, controlName: K, lookup?: string): Observable { + const control = form.controls[controlName]; + // Get a subset of error messages based on the lookup key, if provided, or the control name. + const messagesForControl = lookup + ? ERROR_MAP[lookup] + : ERROR_MAP[controlName] ?? ERROR_MAP["form"]; + return control.statusChanges.pipe( + map(() => mapErrorsToMessages(control, messagesForControl)) + ); +} + +/** + * Watches a FormGroup and turns its errors into an array of string messages. + * + * Uses the optional parameter `lookup` to determine which error messages from `ERROR_MAP` to use. If no `lookup` is + * provided, or if no error messages are found, `ERROR_MAP['form']` is used. + * + * @param form The form group to check for errors. + * @param lookup Optional parameter to specify a specific error lookup key. + * @returns An observable that emits an array of error messages. + */ +export function formErrorMessages$( + form: F, + lookup?: string +): Observable { + const messagesForForm = lookup ? ERROR_MAP[lookup] : ERROR_MAP["form"]; + return form.statusChanges.pipe( + map(() => mapErrorsToMessages(form, messagesForForm)) + ); +} + +/** + * Maps control errors to error messages using the provided error map. + * + * If no message is specified for an error key, the error value itself is used as the message. + * + * @param control - The control containing the errors. + * @param errorMap - The map of error keys to error messages. + * @returns An array of error messages. + */ +function mapErrorsToMessages( + control: AbstractControl, + errorMap: Record +): string[] { + const errors = control.errors ?? {}; + return Object.keys(errors).map((errorKey) => + errorKey in errorMap ? errorMap[errorKey] : errors[errorKey] + ); +} + +/** + * Updates the validity of all controls in the given form and the form itself. + * + * @param form - The form group to update. + */ +export function updateFormValidity(form: FormGroup): void { + Object.values(form.controls).forEach((control) => { + control.updateValueAndValidity(); + }); + form.updateValueAndValidity(); +}
Based on: + + {{ details.baseProblemId }} + + + None{{ details.baseProblemId }}
Section: