From 672fce19db478b9a6e0132e3281461031184e981 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 13 May 2026 11:16:19 +0200 Subject: [PATCH 01/10] Add 'hidden' field to Problem model; create migration --- .../problem/migrations/0009_problem_hidden.py | 18 ++++++++++++++++++ backend/problem/models.py | 2 ++ 2 files changed, 20 insertions(+) create mode 100644 backend/problem/migrations/0009_problem_hidden.py diff --git a/backend/problem/migrations/0009_problem_hidden.py b/backend/problem/migrations/0009_problem_hidden.py new file mode 100644 index 0000000..2b85f8d --- /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 862e86d..ba8bc24 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: From a9c4b49b3293a25926197d9a4705697d74cdd4ac Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 13 May 2026 11:17:38 +0200 Subject: [PATCH 02/10] Add 'hidden' to ProblemSerializer and add permission helper method to User --- backend/problem/serializers.py | 3 ++- backend/user/models.py | 7 +++++++ backend/user/serializers.py | 8 +++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/problem/serializers.py b/backend/problem/serializers.py index 76a05ca..8e58a83 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/user/models.py b/backend/user/models.py index 4578226..4f64d78 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -65,6 +65,13 @@ def can_copy_problem(self) -> bool: """ return self.has_perm("problem.copy_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 ba3f4a4..3f0fc06 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"] From 32717d115b888ef8b22b981ede1072d9515caa97 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 13 May 2026 11:18:13 +0200 Subject: [PATCH 03/10] Add set-visibility action --- backend/problem/views/problem.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/backend/problem/views/problem.py b/backend/problem/views/problem.py index 7653d70..3af276b 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. From 550f1f2cb1b4622b84bd8e9cc3f17b57e2b2a9dd Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 13 May 2026 11:19:51 +0200 Subject: [PATCH 04/10] Add VisibilityToggleComponent --- .../problem-details.component.html | 8 ++++- .../problem-details.component.ts | 5 +++ .../visibility-toggle.component.html | 33 +++++++++++++++++++ .../visibility-toggle.component.scss | 0 .../visibility-toggle.component.spec.ts | 23 +++++++++++++ .../visibility-toggle.component.ts | 29 ++++++++++++++++ frontend/src/app/services/problem.service.ts | 27 ++++++++++++--- frontend/src/app/types.ts | 5 +++ 8 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.scss create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.ts 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 b5f98c2..545d2dd 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,12 @@ @let appMode = appMode$ | async; +@let currentUser = currentUser$ | async; @if (problemDetails(); as details) { -
+
+ @if (currentUser?.canChangeProblemVisibility) { +
+ +
+ } 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 78c4785..afa0786 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(); private problemService = inject(ProblemService); + private authService = inject(AuthService); public appMode$ = this.problemService.appMode$; + public currentUser$ = this.authService.currentUser$; public problemDetails = computed(() => { const problem = this.problem(); diff --git a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html new file mode 100644 index 0000000..ac283e3 --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html @@ -0,0 +1,33 @@ +@let hidden = problem().hidden; + +
+ + @if (hidden) { + Problem is hidden + } @else { + Problem is visible + } + +
diff --git a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.scss b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts new file mode 100644 index 0000000..457f87f --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VisibilityToggleComponent } from './visibility-toggle.component'; + +describe('VisibilityToggleComponent', () => { + let component: VisibilityToggleComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VisibilityToggleComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(VisibilityToggleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.ts b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.ts new file mode 100644 index 0000000..07cbf3b --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.ts @@ -0,0 +1,29 @@ +import { ProblemService } from '@/services/problem.service'; +import { Problem } from '@/types'; +import { Component, inject, input } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'la-visibility-toggle', + imports: [FontAwesomeModule, NgbTooltipModule], + templateUrl: './visibility-toggle.component.html', + styleUrl: './visibility-toggle.component.scss' +}) +export class VisibilityToggleComponent { + public readonly problem = input.required(); + private problemService = inject(ProblemService); + + public faEye = faEye; + public faEyeSlash = faEyeSlash; + + public onToggleHidden(): void { + const problem = this.problem(); + if (!problem?.id) { + return; + } + this.problemService.toggleVisibility$.next({ id: problem.id, hidden: !problem.hidden }); + } + +} diff --git a/frontend/src/app/services/problem.service.ts b/frontend/src/app/services/problem.service.ts index 9782cfa..b5f5744 100644 --- a/frontend/src/app/services/problem.service.ts +++ b/frontend/src/app/services/problem.service.ts @@ -1,6 +1,6 @@ import { ParseInput } from "@/annotate/annotation-input/annotation-input.component"; import extractBaseParam from "@/shared/extractBaseParam"; -import { ProblemResponse, SaveProblemResponse, Dataset, EntailmentLabel, Problem, Label } from "@/types"; +import { ProblemResponse, SaveProblemResponse, Dataset, EntailmentLabel, Problem, Label, ToggleVisibilityInput } from "@/types"; import { HttpClient, HttpParams } from "@angular/common/http"; import { Injectable, inject } from "@angular/core"; import { ParamMap } from "@angular/router"; @@ -28,14 +28,32 @@ export class ProblemService { public allParams$ = new BehaviorSubject(null); - public refetchProblem$ = new Subject(); - // Submit a new problem to be saved to the database. public submit$ = new Subject(); + public refetchProblem$ = new Subject(); + public toggleVisibility$ = new Subject(); + + private visibilityToggleSuccess$ = this.toggleVisibility$.pipe( + exhaustMap(({ id, hidden }) => + this.http.post(`/api/problem/${id}/set-visibility/`, { hidden }).pipe( + catchError((error) => { + this.toastService.show({ + header: $localize`Error updating visibility`, + body: error.message || $localize`Could not update problem visibility.`, + type: 'danger', + }); + return of(null); + }) + ) + ), + filter(result => result !== null), + map(() => this.allParams$.value), + ); public problemResponse$: Observable = merge( this.allParams$, - this.refetchProblem$.pipe(map(() => this.allParams$.value)) + this.refetchProblem$.pipe(map(() => this.allParams$.value)), + this.visibilityToggleSuccess$, ).pipe( filter(allParams => allParams !== null), switchMap(({ params, queryParams }) => { @@ -131,6 +149,7 @@ export class ProblemService { dataset: Dataset.USER, premises: existingProblem?.premises ?? [], entailmentLabel: EntailmentLabel.UNKNOWN, + hidden: false, extraData: null, kbAnnotations: existingProblem?.kbAnnotations.map(annotation => ({ ...annotation, id: null, diff --git a/frontend/src/app/types.ts b/frontend/src/app/types.ts index f097e28..522dce8 100644 --- a/frontend/src/app/types.ts +++ b/frontend/src/app/types.ts @@ -64,6 +64,7 @@ interface ProblemBase { premises: string[]; hypothesis: string | null; entailmentLabel: EntailmentLabel; + hidden: boolean; kbAnnotations: KnowledgeBaseAnnotation[]; labelAnnotations: LabelAnnotation[]; } @@ -196,3 +197,7 @@ export interface ProofTree { nodes: ProofNode[]; subtrees?: ProofTree[]; } + +export interface ToggleVisibilityInput { + hidden: boolean; +} From 8f84ba0cba5139b12e4d299431895a7cff29e182 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 13 May 2026 11:25:23 +0200 Subject: [PATCH 05/10] Update frontend user types --- frontend/src/app/user/models/user.ts | 2 ++ frontend/src/app/user/utils.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/frontend/src/app/user/models/user.ts b/frontend/src/app/user/models/user.ts index daf5b32..66d389a 100644 --- a/frontend/src/app/user/models/user.ts +++ b/frontend/src/app/user/models/user.ts @@ -11,6 +11,7 @@ export interface UserResponse { canEditKb: boolean; canAddLabelAnnotations: boolean; canCopyProblem: boolean; + canChangeProblemVisibility: boolean; } // Corresponds to frontend user type. @@ -35,6 +36,7 @@ export class User { public canEditKb: boolean, public canAddLabelAnnotations: boolean, public canCopyProblem: boolean, + public canChangeProblemVisibility: boolean, ) { } } diff --git a/frontend/src/app/user/utils.ts b/frontend/src/app/user/utils.ts index a13f33e..f6d7079 100644 --- a/frontend/src/app/user/utils.ts +++ b/frontend/src/app/user/utils.ts @@ -35,6 +35,7 @@ export const parseUserData = (result: UserResponse | null): User | null => { result.canEditKb, result.canAddLabelAnnotations, result.canCopyProblem, + result.canChangeProblemVisibility, ); }; From 59594db90761b55f57046ad01cf0b39cf51389a8 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 14 May 2026 09:36:01 +0200 Subject: [PATCH 06/10] Change visibility toggle component to button with banner --- .../problem-details.component.html | 9 ++- .../visibility-toggle.component.html | 56 +++++++++---------- .../visibility-toggle.component.scss | 19 +++++++ .../visibility-toggle.component.ts | 3 +- 4 files changed, 52 insertions(+), 35 deletions(-) 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 545d2dd..7e8fa4e 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,11 +1,10 @@ @let appMode = appMode$ | async; @let currentUser = currentUser$ | async; + @if (problemDetails(); as details) { -
+
@if (currentUser?.canChangeProblemVisibility) { -
- -
+ }
@@ -31,7 +30,7 @@ } @else if (details.baseProblemId === null) { diff --git a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html index ac283e3..32789d5 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html +++ b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html @@ -1,33 +1,31 @@ @let hidden = problem().hidden; - -
- - @if (hidden) { - Problem is hidden - } @else { - Problem is visible - } - -
{{ details.baseProblemId }} - +