diff --git a/backend/conftest.py b/backend/conftest.py index 176bf9f6..5b83abb6 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -125,6 +125,20 @@ def sample_problem(db): problem.premises.add(premise) return problem +@pytest.fixture +def hidden_problem(db): + """Creates a hidden problem for testing.""" + hypothesis = Sentence.objects.create(text="This is a hidden hypothesis.") + premise = Sentence.objects.create(text="This is a hidden premise.") + problem = Problem.objects.create( + dataset=Problem.Dataset.USER, + hypothesis=hypothesis, + entailment_label=Problem.EntailmentLabel.NEUTRAL, + extra_data={}, + hidden=True, + ) + problem.premises.add(premise) + return problem @pytest.fixture def annotator_session(db, annotator): diff --git a/backend/problem/migrations/0009_problem_hidden.py b/backend/problem/migrations/0009_problem_hidden.py new file mode 100644 index 00000000..2b85f8d5 --- /dev/null +++ b/backend/problem/migrations/0009_problem_hidden.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.27 on 2026-05-13 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("problem", "0008_delete_knowledgebase"), + ] + + operations = [ + migrations.AddField( + model_name="problem", + name="hidden", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/problem/models.py b/backend/problem/models.py index 862e86d4..ba8bc247 100644 --- a/backend/problem/models.py +++ b/backend/problem/models.py @@ -53,6 +53,8 @@ class EntailmentLabel(models.TextChoices): default=EntailmentLabel.UNKNOWN, ) + hidden = models.BooleanField(default=False) + extra_data = models.JSONField() class Meta: diff --git a/backend/problem/problem_details.py b/backend/problem/problem_details.py index cd5b2133..7cf74c9e 100644 --- a/backend/problem/problem_details.py +++ b/backend/problem/problem_details.py @@ -5,6 +5,7 @@ from langpro_annotator.logger import logger from problem.models import Problem +from user.models import User @dataclass @@ -51,7 +52,7 @@ def get_related_problem_ids( ) -def get_filters(query_params: QueryDict) -> Q | None: +def get_filters(query_params: QueryDict, user: User | None = None) -> Q | None: """ Constructs a Django Q object for filtering problems based on query parameters. Return None if no valid filters are found in the parameters. @@ -60,9 +61,9 @@ def get_filters(query_params: QueryDict) -> Q | None: entailment_label = query_params.get("entailmentLabel") gold = query_params.get("gold") text = query_params.get("text") + hidden = query_params.get("hidden", None) - if not (dataset or entailment_label or gold or text): - return None + user_can_see_hidden = user.can_see_hidden_problems if user else False filters = Q() if dataset: @@ -77,4 +78,9 @@ def get_filters(query_params: QueryDict) -> Q | None: Q(hypothesis__text__icontains=text) | Q(premises__text__icontains=text) ) + if not user_can_see_hidden: + filters &= Q(hidden=False) + elif hidden and hidden.lower() in ('true', 'false'): + filters &= Q(hidden=hidden.lower() == 'true') + return filters diff --git a/backend/problem/serializers.py b/backend/problem/serializers.py index 76a05ca3..8e58a837 100644 --- a/backend/problem/serializers.py +++ b/backend/problem/serializers.py @@ -32,6 +32,7 @@ class Meta: "entailmentLabel", "extraData", "base", + "hidden", ] def get_premises(self, problem: Problem): @@ -181,7 +182,7 @@ def update(self, instance: Problem, validated_data: dict) -> Problem: return instance return self._update_core_problem_fields(instance, validated_data) - + def _update_core_problem_fields(self, instance: Problem, validated_data: dict) -> Problem: """ Updates core Problem fields (premises, hypothesis, base) from validated diff --git a/backend/problem/views/problem.py b/backend/problem/views/problem.py index 7653d700..99d70882 100644 --- a/backend/problem/views/problem.py +++ b/backend/problem/views/problem.py @@ -5,6 +5,7 @@ from rest_framework.status import ( HTTP_201_CREATED, HTTP_200_OK, + HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, ) @@ -39,6 +40,11 @@ def has_permission(self, request, view): ) +class ChangeProblemVisibilityPermission(IsAuthenticated): + def has_permission(self, request, view): + return super().has_permission(request, view) and request.user.can_change_problem_visibility + + class ProblemView(ModelViewSet): queryset = Problem.objects.all() serializer_class = ProblemSerializer @@ -48,6 +54,8 @@ def get_permissions(self): return [CreateProblemPermission()] if self.action == "partial_update": return [EditProblemPermission()] + if self.action == "set_visibility": + return [ChangeProblemVisibilityPermission()] return [IsAuthenticatedOrReadOnly()] def list(self, request: Request) -> Response: @@ -71,6 +79,23 @@ def first(self, request: Request) -> Response: """ return self._get_problem_response(request, pk=None) + @action(detail=True, methods=["post"], url_path="set-visibility") + def set_visibility(self, request: Request, pk: int) -> Response: + """ + Toggles the hidden status of a Problem. + Expects a JSON body with a boolean 'hidden' field. + """ + problem = get_object_or_404(Problem, id=pk) + hidden = request.data.get("hidden") + if not isinstance(hidden, bool): + return Response( + {"detail": "'hidden' must be a boolean."}, + status=HTTP_400_BAD_REQUEST, + ) + problem.hidden = hidden + problem.save(update_fields=["hidden"]) + return Response({"hidden": problem.hidden}, status=HTTP_200_OK) + def retrieve(self, request: Request, pk: int | None = None) -> Response: """ Retrieves the requested Problem by ID. @@ -82,7 +107,8 @@ def _get_problem_response(self, request: Request, pk: int | None) -> Response: Helper method to build the problem response. If pk is provided, retrieves that problem; otherwise returns the first problem. """ - filters = get_filters(request.query_params) + user = request.user if request.user.is_authenticated else None + filters = get_filters(request.query_params, user) qs = self.get_queryset() diff --git a/backend/problem/views/problem_test.py b/backend/problem/views/problem_test.py index 678dac8c..21851ab5 100644 --- a/backend/problem/views/problem_test.py +++ b/backend/problem/views/problem_test.py @@ -81,6 +81,42 @@ def test_master_annotator_can_retrieve_problem( response = client.get(f"/api/problem/{sample_problem.id}/") assert response.status_code == status.HTTP_200_OK + # Retrieve hidden problems + + def test_user_without_permission_cannot_see_hidden_problem( + self, client, annotator, hidden_problem, sample_problem + ): + """Unauthenticated users should not be able to see hidden problems.""" + client.force_login(user=annotator) + response = client.get(f"/api/problem/{hidden_problem.id}/") + assert response.status_code == status.HTTP_200_OK + assert response.data['problem']['id'] == sample_problem.id, "Unauthenticated users requesting a hidden problem should receive the first non-hidden problem instead." + + def test_visitor_cannot_see_hidden_problem(self, client, visitor, hidden_problem, sample_problem): + """Visitors should not be able to see hidden problems.""" + client.force_login(user=visitor) + response = client.get(f"/api/problem/{hidden_problem.id}/") + assert response.status_code == status.HTTP_200_OK + assert response.data['problem']['id'] == sample_problem.id, "Visitors requesting a hidden problem should receive the first non-hidden problem instead." + + def test_annotator_cannot_see_hidden_problem( + self, client, annotator, hidden_problem, sample_problem + ): + """Annotators should not be able to see hidden problems.""" + client.force_login(user=annotator) + response = client.get(f"/api/problem/{hidden_problem.id}/") + assert response.status_code == status.HTTP_200_OK + assert response.data['problem']['id'] == sample_problem.id, "Annotators requesting a hidden problem should receive the first non-hidden problem instead." + + def test_master_annotator_can_see_hidden_problem( + self, client, master_annotator, hidden_problem + ): + """Master annotators should be able to see hidden problems.""" + client.force_login(user=master_annotator) + response = client.get(f"/api/problem/{hidden_problem.id}/") + assert response.status_code == status.HTTP_200_OK + assert response.data['problem']['id'] == hidden_problem.id, "Master annotators requesting a hidden problem should receive that hidden problem." + # Create def test_unauthenticated_user_cannot_create_problem( @@ -237,6 +273,63 @@ def test_master_annotator_cannot_update_non_user_problem( assert sample_problem.hypothesis.text == old_hypothesis assert sample_problem.premises.first().text == old_premise + # Update hidden status + + def test_unauthenticated_user_cannot_update_problem_visibility( + self, client, sample_problem + ): + """Unauthenticated users should not be able to update problem visibility.""" + response = client.post( + f"/api/problem/{sample_problem.id}/set-visibility/", + {"hidden": True}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + sample_problem.refresh_from_db() + assert sample_problem.hidden is False + + def test_visitor_cannot_update_problem_visibility( + self, client, visitor, sample_problem + ): + """Visitors should not be able to update problem visibility.""" + client.force_login(user=visitor) + response = client.post( + f"/api/problem/{sample_problem.id}/set-visibility/", + {"hidden": True}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + sample_problem.refresh_from_db() + assert sample_problem.hidden is False + + def test_annotator_cannot_update_problem_visibility( + self, client, annotator, sample_problem + ): + """Annotators should not be able to update problem visibility.""" + client.force_login(user=annotator) + response = client.post( + f"/api/problem/{sample_problem.id}/set-visibility/", + {"hidden": True}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + sample_problem.refresh_from_db() + assert sample_problem.hidden is False + + def test_master_annotator_can_update_problem_visibility( + self, client, master_annotator, sample_problem + ): + """Master annotators should be able to update problem visibility.""" + client.force_login(user=master_annotator) + response = client.post( + f"/api/problem/{sample_problem.id}/set-visibility/", + {"hidden": True}, + content_type="application/json", + ) + assert response.status_code == status.HTTP_200_OK + sample_problem.refresh_from_db() + assert sample_problem.hidden is True + # Update KB annotations def test_unauthenticated_user_cannot_update_kb_annotations( diff --git a/backend/user/models.py b/backend/user/models.py index 45782265..6b2df2b3 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -65,6 +65,20 @@ def can_copy_problem(self) -> bool: """ return self.has_perm("problem.copy_problems") + @property + def can_see_hidden_problems(self) -> bool: + """ + Determines whether the user can see hidden problems. + """ + return self.has_perm("problem.view_hidden_problems") + + @property + def can_change_problem_visibility(self) -> bool: + """ + Determines whether the user can change problem visibility (hidden status). + """ + return self.has_perm("problem.change_problem_visibility") + @property def can_edit_kb(self) -> bool: """ diff --git a/backend/user/serializers.py b/backend/user/serializers.py index ba3f4a40..3f0fc062 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -12,8 +12,13 @@ class CustomUserDetailsSerializer(UserDetailsSerializer): read_only=True, source="can_create_problem" ) canEditKb = serializers.BooleanField(read_only=True, source="can_edit_kb") - canAddLabelAnnotations = serializers.BooleanField(read_only=True, source="can_add_label_annotations") + canAddLabelAnnotations = serializers.BooleanField( + read_only=True, source="can_add_label_annotations" + ) canCopyProblem = serializers.BooleanField(read_only=True, source="can_copy_problem") + canChangeProblemVisibility = serializers.BooleanField( + read_only=True, source="can_change_problem_visibility" + ) class Meta(UserDetailsSerializer.Meta): @@ -30,5 +35,6 @@ class Meta(UserDetailsSerializer.Meta): "canEditKb", "canAddLabelAnnotations", "canCopyProblem", + "canChangeProblemVisibility", ) 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 e6cf88ea..4d3fdbc5 100644 --- a/backend/user/tests/test_user_views.py +++ b/backend/user/tests/test_user_views.py @@ -17,6 +17,7 @@ def test_user_details(user_client, user_data): "canEditKb": False, "canCopyProblem": False, "canAddLabelAnnotations": False, + "canChangeProblemVisibility": False, } diff --git a/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts b/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts index 38db77f1..ee61737f 100644 --- a/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts +++ b/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts @@ -50,6 +50,7 @@ describe("AnnotationInputComponent", () => { const mockProblem: Problem = { id: 123, base: null, + hidden: false, premises: ["First premise", "Second premise"], hypothesis: "Test hypothesis", entailmentLabel: EntailmentLabel.ENTAILMENT, @@ -121,6 +122,7 @@ describe("AnnotationInputComponent", () => { const mockProblem: Problem = { id: 123, base: null, + hidden: false, premises: [], hypothesis: "Empty test hypothesis", entailmentLabel: EntailmentLabel.NEUTRAL, @@ -143,6 +145,7 @@ describe("AnnotationInputComponent", () => { const mockProblem: Problem = { id: 1, base: null, + hidden: false, premises: ["Test premise"], hypothesis: "Test hypothesis", entailmentLabel: EntailmentLabel.CONTRADICTION, @@ -166,6 +169,7 @@ describe("AnnotationInputComponent", () => { const mockProblem: Problem = { id: 12, base: null, + hidden: false, premises: [], hypothesis: "", entailmentLabel: EntailmentLabel.UNKNOWN, @@ -187,6 +191,7 @@ describe("AnnotationInputComponent", () => { const mockProblem: Problem = { id: 17, base: null, + hidden: false, premises: [], hypothesis: "", entailmentLabel: EntailmentLabel.UNKNOWN, 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 b5f98c2f..7e8fa4e3 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,6 +1,11 @@ @let appMode = appMode$ | async; +@let currentUser = currentUser$ | async; + @if (problemDetails(); as details) {
|
{{ details.baseProblemId }}
- |
} @else if (details.baseProblemId === null) {
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 436fc1ae..26cf4327 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
@@ -12,6 +12,7 @@ const createMockProblem = (
): Problem => ({
id,
base: null,
+ hidden: false,
dataset,
entailmentLabel,
premises: ["premise"],
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 78c47856..afa0786f 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
@@ -9,6 +9,8 @@ import { ProblemLabelsComponent } from "./problem-labels/problem-labels.componen
import { CommonModule } from "@angular/common";
import { RouterModule } from "@angular/router";
import { ProblemService } from "@/services/problem.service";
+import { AuthService } from "@/services/auth.service";
+import { VisibilityToggleComponent } from "./visibility-toggle/visibility-toggle.component";
export interface ProblemDetails {
problemId: string;
@@ -31,6 +33,7 @@ export interface ProblemDetails {
NgbTooltipModule,
CommonModule,
RouterModule,
+ VisibilityToggleComponent
],
templateUrl: "./problem-details.component.html",
styleUrl: "./problem-details.component.scss",
@@ -38,8 +41,10 @@ export interface ProblemDetails {
export class ProblemDetailsComponent {
public readonly problem = input.required