Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
fc79f61
Add Icon Button Component
XanderVertegaal Sep 22, 2025
c7d3134
Add copy button
XanderVertegaal Sep 22, 2025
c55719c
Avoid unnecessary requests
XanderVertegaal Sep 22, 2025
5440a8a
Compare route params
XanderVertegaal Sep 22, 2025
d42d1e2
Add base field to form
XanderVertegaal Sep 22, 2025
7b4796f
Add base field to Problem
XanderVertegaal Sep 22, 2025
e7da8fc
Use Problem.field in problem views
XanderVertegaal Sep 22, 2025
07297b3
Use update_or_create for sentences
XanderVertegaal Sep 22, 2025
aadfd32
Use base field in frontend
XanderVertegaal Sep 22, 2025
60a2e5a
Simplify Annotate component template
XanderVertegaal Sep 22, 2025
bcb27e9
Keep queryParams after form update
XanderVertegaal Sep 22, 2025
a68c25a
Fix failing tests
XanderVertegaal Sep 22, 2025
1e089b0
Merge branch 'feature/user-problems' into feature/derived-problems
XanderVertegaal Oct 25, 2025
658a1fe
Add base field to serializer
XanderVertegaal Oct 25, 2025
8c23335
Add Replace ViewMode with AppMode in ProblemDetailsComponent
XanderVertegaal Oct 25, 2025
d0e0d34
Remove unused import
XanderVertegaal Oct 25, 2025
c06aa05
Merge branch 'feature/user-problems' into feature/derived-problems
XanderVertegaal Oct 25, 2025
e49e3d6
Add role field to User model
XanderVertegaal Nov 4, 2025
d627060
Add role to serializer
XanderVertegaal Nov 4, 2025
26a629b
Implement frontend add/edit checks
XanderVertegaal Nov 4, 2025
1bd7eea
Route guard
XanderVertegaal Nov 4, 2025
b3d717f
Role-based user icons
XanderVertegaal Nov 4, 2025
63d8684
Add backend permission checks
XanderVertegaal Nov 4, 2025
fde5405
Tweak admin and serializer
XanderVertegaal Nov 4, 2025
a5c44c5
Wait in CanEditOrAddGuard
XanderVertegaal Nov 4, 2025
7e75de2
Fix route guard
XanderVertegaal Nov 4, 2025
aff8c7b
Fix frontend tests
XanderVertegaal Nov 4, 2025
e940059
Fix backend tests
XanderVertegaal Nov 4, 2025
7897f67
Merge branch 'feature/user-problems' into feature/derived-problems
XanderVertegaal Dec 7, 2025
0428b12
Outfactor util
XanderVertegaal Dec 7, 2025
5006b4c
Set base in serializer
XanderVertegaal Dec 7, 2025
873b040
Reinstate IconButton for non-navigational buttons
XanderVertegaal Dec 7, 2025
b4ec78d
Remove unused imports
XanderVertegaal Dec 7, 2025
bea3b3e
Silence Python type warning
XanderVertegaal Dec 7, 2025
2ef9935
Return base correctly
XanderVertegaal Dec 8, 2025
f43bd3f
Avoid duplicate results upon filtering
XanderVertegaal Dec 8, 2025
7188e7f
Outfactor general utility function
XanderVertegaal Dec 8, 2025
5b3cbeb
Merge branch 'feature/derived-problems' into feature/user-models
XanderVertegaal Dec 8, 2025
ecbb706
Remove User.role; use Django permission system instead
XanderVertegaal Dec 8, 2025
5b5c941
Add data migration for user groups
XanderVertegaal Dec 8, 2025
bb034f7
Document user permissions
XanderVertegaal Dec 8, 2025
31bac00
Use new permission checks in frontend
XanderVertegaal Dec 8, 2025
045a612
Major Tom
XanderVertegaal Dec 8, 2025
b169462
Fix DRF Permission classes
XanderVertegaal Dec 8, 2025
856832e
Reuse permissions in tests
XanderVertegaal Dec 11, 2025
27f7a0f
Fix failing tests
XanderVertegaal Dec 11, 2025
5acbcb0
Use appropriate casing
XanderVertegaal Dec 11, 2025
49fadb4
Fix frontend tests as well
XanderVertegaal Dec 11, 2025
0b2b8c2
Reinstate illegally removed whitelines
XanderVertegaal Dec 12, 2025
e0733f9
Use built-in client for tests
XanderVertegaal Dec 12, 2025
65e2bc7
Remove unused imports
XanderVertegaal Dec 12, 2025
140a505
Reinstate proper indentation
XanderVertegaal Dec 12, 2025
c666067
Outfactor user2icon
XanderVertegaal Dec 12, 2025
3f69dfb
Reduce excessive nesting in newProblem$ observable
XanderVertegaal Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions backend/problem/views/problem.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from dataclasses import asdict, dataclass

from django.db import DatabaseError
from django.core.exceptions import PermissionDenied
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
from rest_framework.views import APIView

from user.models import User
from langpro_annotator.logger import logger
from problem.problem_details import get_filters, get_related_problem_ids
from problem.models import KnowledgeBase, Problem, Sentence
Expand Down Expand Up @@ -91,9 +94,10 @@ def post(self, request, problem_id: int | None = None) -> JsonResponse:
else the associated Problem is updated.
"""
input_data = request.data
user: User | AnonymousUser | None = request.user

try:
problem = save_problem(input_data, problem_id)
problem = save_problem(input_data, problem_id, user)
except ValueError as ve:
logger.error(f"Validation error saving problem: {ve}")
return SaveProblemResponse(id=problem_id, error=str(ve)).json_response(
Expand All @@ -114,6 +118,11 @@ def post(self, request, problem_id: int | None = None) -> JsonResponse:
return SaveProblemResponse(
id=problem_id, error="Database error saving problem."
).json_response(status=500)
except PermissionDenied as pde:
logger.error(f"Permission denied saving problem: {pde}")
return SaveProblemResponse(
id=problem_id, error="Permission denied."
).json_response(status=403)
except Exception as e:
logger.exception(f"Unexpected error saving problem: {e}")
return SaveProblemResponse(
Expand All @@ -123,7 +132,13 @@ def post(self, request, problem_id: int | None = None) -> JsonResponse:
return SaveProblemResponse(id=problem.pk).json_response(status=200)


def save_problem(input_data: dict, problem_id: int | None) -> Problem:
def save_problem(input_data: dict, problem_id: int | None, user: User | AnonymousUser | None) -> Problem:
if user is None or user.is_anonymous:
raise PermissionDenied("User must be authenticated to edit/create problems.")

if not user.can_edit_or_add_problem: # type: ignore
raise PermissionDenied("User does not have role required to edit/create problems.")

serializer = ProblemInputSerializer(data=input_data)

if not serializer.is_valid():
Expand Down
53 changes: 45 additions & 8 deletions backend/user/admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,45 @@
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 typing import Any
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.http import HttpRequest
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", "role", "is_staff")

def get_fieldsets(
self, request: HttpRequest, obj: models.User | None = None
) -> list[tuple[str | None, dict[str, Any]]]:
user = request.user
if user.is_superuser:
admin_fieldset = super().get_fieldsets(request, obj)
if len(admin_fieldset) >= 3:
# Add the 'role' field to the admin fieldset for superusers.
admin_fieldset[2][1]["fields"] = (
"is_active",
"is_staff",
"is_superuser",
"role",
"groups",
"user_permissions",
)
return admin_fieldset

# Non-superusers should not be able to edit password, is_superuser and groups through Django Admin.
return [
(None, {"fields": ("username",)}),
(_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
(
_("Permissions"),
{
"fields": (
"is_active",
"is_staff",
"role",
),
},
),
]
27 changes: 27 additions & 0 deletions backend/user/migrations/0004_user_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.2.20 on 2025-11-04 10:51

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("user", "0003_sitedomain"),
]

operations = [
migrations.AddField(
model_name="user",
name="role",
field=models.CharField(
choices=[
("annotator", "Annotator"),
("master_annotator", "Master Annotator"),
("visitor", "Visitor"),
],
default="visitor",
help_text="Visitors can browse problems and parses. Annotators can annotate non-locked problems. Master Annotators can manage users, change problem lock status, and review annotations.",
max_length=20,
),
),
]
47 changes: 33 additions & 14 deletions backend/user/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
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"
import django.contrib.auth.models as django_auth_models
from django.db import 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"

class Role(models.TextChoices):
ANNOTATOR = "annotator", "Annotator"
MASTER_ANNOTATOR = "master_annotator", "Master Annotator"
VISITOR = "visitor", "Visitor"

role = models.CharField(
max_length=20,
choices=Role.choices,
default=Role.VISITOR,
help_text="Visitors can browse problems and parses. Annotators can annotate non-locked problems. Master Annotators can manage users, change problem lock status, and review annotations.",
)
@property
def can_edit_or_add_problem(self) -> bool:
"""
Determines whether the user can edit or add problems.
"""
return self.is_superuser or self.role in [self.Role.MASTER_ANNOTATOR]
40 changes: 23 additions & 17 deletions backend/user/serializers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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')
canEditOrAddProblem = serializers.BooleanField(read_only=True, source='can_edit_or_add_problem')

class Meta(UserDetailsSerializer.Meta):

fields = (
"id",
"username",
"email",
"firstName",
"lastName",
"isStaff",
"role",
"canEditOrAddProblem",
)
read_only_fields = ["isStaff", "id", "email"]
76 changes: 39 additions & 37 deletions backend/user/tests/test_user_views.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
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",
"canEditOrAddProblem": 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"]
55 changes: 28 additions & 27 deletions frontend/src/app/annotate/annotate.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
@let adding = appMode === 'add';
@let editing = appMode === 'edit';


<div class="row mb-4">
<div class="d-flex flex-column col-4 gap-2">
@if (browsing) {
Expand Down Expand Up @@ -61,32 +60,34 @@
</section>
}
<div class="d-flex flex-column gap-2 align-items-stretch">
@if (browsing) {
<button
type="button"
class="btn btn-secondary d-flex align-items-center justify-content-center"
(click)="addProblem()"
>
<fa-icon
[icon]="faPlus"
aria-hidden="true"
focusable="false"
></fa-icon>
<span class="ms-3" i18n>Add new problem</span>
</button>
} @else {
<button
type="button"
class="btn btn-secondary d-flex align-items-center justify-content-center"
(click)="firstProblemId && goToProblem(firstProblemId)"
>
<fa-icon
[icon]="faBinoculars"
aria-hidden="true"
focusable="false"
></fa-icon>
<span class="ms-3" i18n>Browse existing problems</span>
</button>
@if (canAddProblem$ | async) {
@if (browsing) {
<button
type="button"
class="btn btn-secondary d-flex align-items-center justify-content-center"
(click)="addProblem()"
>
<fa-icon
[icon]="faPlus"
aria-hidden="true"
focusable="false"
></fa-icon>
<span class="ms-3" i18n>Add new problem</span>
</button>
} @else {
<button
type="button"
class="btn btn-secondary d-flex align-items-center justify-content-center"
(click)="firstProblemId && goToProblem(firstProblemId)"
>
<fa-icon
[icon]="faBinoculars"
aria-hidden="true"
focusable="false"
></fa-icon>
<span class="ms-3" i18n>Browse existing problems</span>
</button>
}
}
</div>
</div>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app/annotate/annotate.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { combineLatest, distinctUntilChanged, map } from "rxjs";
import { CommonModule } from "@angular/common";
import { Dataset } from "@/types";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { AuthService } from "@/services/auth.service";

@Component({
selector: "la-annotate",
Expand All @@ -30,6 +31,7 @@ export class AnnotateComponent implements OnInit {
private router = inject(Router);
private route = inject(ActivatedRoute);
private problemService = inject(ProblemService);
private authService = inject(AuthService);
private destroyRef = inject(DestroyRef);

public faPlus = faPlus;
Expand All @@ -42,6 +44,10 @@ export class AnnotateComponent implements OnInit {
map(problem => problem?.dataset === Dataset.USER)
);

public canAddProblem$ = this.authService.currentUser$.pipe(
map(user => user?.canEditOrAddProblem ?? false)
);

ngOnInit(): void {
const editParam$ = this.route.url.pipe(
map(segments => segments.some(segment => segment.path === "edit")),
Expand Down
Loading