diff --git a/backend/src/openarchiefbeheer/accounts/admin.py b/backend/src/openarchiefbeheer/accounts/admin.py index d615d288..7eb97757 100644 --- a/backend/src/openarchiefbeheer/accounts/admin.py +++ b/backend/src/openarchiefbeheer/accounts/admin.py @@ -3,9 +3,10 @@ from django.contrib.auth.admin import UserAdmin as _UserAdmin from django.core.exceptions import PermissionDenied, ValidationError from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ from .forms import PreventPrivilegeEscalationMixin, UserChangeForm -from .models import User +from .models import Role, User from .utils import validate_max_user_permissions @@ -13,6 +14,7 @@ class UserAdmin(_UserAdmin): hijack_success_url = reverse_lazy("root") form = UserChangeForm + list_display = _UserAdmin.list_display + ("role",) def get_form(self, request, obj=None, **kwargs): ModelForm = super().get_form(request, obj, **kwargs) @@ -32,3 +34,17 @@ def user_change_password(self, request, id, form_url=""): raise PermissionDenied from exc return super().user_change_password(request, id, form_url) + + def get_fieldsets(self, request, obj=None): + fieldsets = super().get_fieldsets(request, obj) + return tuple(fieldsets) + ((_("Role"), {"fields": ("role",)}),) + + +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + list_display = ( + "name", + "can_start_destruction", + "can_review_destruction", + "can_view_case_details", + ) diff --git a/backend/src/openarchiefbeheer/accounts/api/__init__.py b/backend/src/openarchiefbeheer/accounts/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/openarchiefbeheer/accounts/api/serializers.py b/backend/src/openarchiefbeheer/accounts/api/serializers.py new file mode 100644 index 00000000..a18dc94f --- /dev/null +++ b/backend/src/openarchiefbeheer/accounts/api/serializers.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from ..models import Role, User + + +class RoleSerializer(serializers.ModelSerializer): + class Meta: + model = Role + fields = ( + "name", + "can_start_destruction", + "can_review_destruction", + "can_view_case_details", + ) + + +class UserSerializer(serializers.ModelSerializer): + role = RoleSerializer() + + class Meta: + model = User + fields = ("username", "first_name", "last_name", "email", "role") diff --git a/backend/src/openarchiefbeheer/accounts/api/views.py b/backend/src/openarchiefbeheer/accounts/api/views.py new file mode 100644 index 00000000..4e859d27 --- /dev/null +++ b/backend/src/openarchiefbeheer/accounts/api/views.py @@ -0,0 +1,25 @@ +from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ + +from drf_spectacular.utils import extend_schema +from rest_framework.generics import ListAPIView + +from openarchiefbeheer.accounts.models import User + +from .serializers import UserSerializer + + +@extend_schema( + summary=_("Reviewers list"), + description=_( + "List all the users that have the permission to review destruction lists." + ), + responses={ + 200: UserSerializer(many=True), + }, +) +class ReviewersView(ListAPIView): + serializer_class = UserSerializer + + def get_queryset(self) -> QuerySet[User]: + return User.objects.reviewers() diff --git a/backend/src/openarchiefbeheer/accounts/managers.py b/backend/src/openarchiefbeheer/accounts/managers.py index 70d0a45c..0ce36ac2 100644 --- a/backend/src/openarchiefbeheer/accounts/managers.py +++ b/backend/src/openarchiefbeheer/accounts/managers.py @@ -32,3 +32,6 @@ def create_superuser(self, username, email, password, **extra_fields): raise ValueError("Superuser must have is_superuser=True.") return self._create_user(username, email, password, **extra_fields) + + def reviewers(self): + return self.select_related("role").filter(role__can_review_destruction=True) diff --git a/backend/src/openarchiefbeheer/accounts/migrations/0002_role_user_role.py b/backend/src/openarchiefbeheer/accounts/migrations/0002_role_user_role.py new file mode 100644 index 00000000..3cb4593a --- /dev/null +++ b/backend/src/openarchiefbeheer/accounts/migrations/0002_role_user_role.py @@ -0,0 +1,76 @@ +# Generated by Django 4.2.11 on 2024-04-30 12:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Role", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text="Name of the role", + max_length=255, + unique=True, + verbose_name="name", + ), + ), + ( + "can_start_destruction", + models.BooleanField( + default=False, + help_text="Indicates whether a user can create a list of cases to be deleted.", + verbose_name="can start destruction", + ), + ), + ( + "can_review_destruction", + models.BooleanField( + default=False, + help_text="Indicates whether a user can review a list of cases to be deleted. They can approve it, reject it or provide feedback.", + verbose_name="can review destruction", + ), + ), + ( + "can_view_case_details", + models.BooleanField( + default=False, + help_text="Indicates whether a user can view the contents of cases in a lists.", + verbose_name="can view case details", + ), + ), + ], + options={ + "verbose_name": "role", + "verbose_name_plural": "roles", + }, + ), + migrations.AddField( + model_name="user", + name="role", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="accounts.role", + verbose_name="role", + ), + ), + ] diff --git a/backend/src/openarchiefbeheer/accounts/models.py b/backend/src/openarchiefbeheer/accounts/models.py index f67dc0e5..7078aa4b 100644 --- a/backend/src/openarchiefbeheer/accounts/models.py +++ b/backend/src/openarchiefbeheer/accounts/models.py @@ -43,6 +43,13 @@ class User(AbstractBaseUser, PermissionsMixin): ), ) date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + role = models.ForeignKey( + "accounts.Role", + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("role"), + ) objects = UserManager() @@ -63,3 +70,38 @@ def get_full_name(self): def get_short_name(self): "Returns the short name for the user." return self.first_name + + +class Role(models.Model): + name = models.CharField( + _("name"), max_length=255, unique=True, help_text=_("Name of the role") + ) + can_start_destruction = models.BooleanField( + _("can start destruction"), + default=False, + help_text=_( + "Indicates whether a user can create a list of cases to be deleted." + ), + ) + can_review_destruction = models.BooleanField( + _("can review destruction"), + default=False, + help_text=_( + "Indicates whether a user can review a list of cases to be deleted. " + "They can approve it, reject it or provide feedback." + ), + ) + can_view_case_details = models.BooleanField( + _("can view case details"), + default=False, + help_text=_( + "Indicates whether a user can view the contents of cases in a lists." + ), + ) + + class Meta: + verbose_name = _("role") + verbose_name_plural = _("roles") + + def __str__(self): + return self.name diff --git a/backend/src/openarchiefbeheer/accounts/tests/factories.py b/backend/src/openarchiefbeheer/accounts/tests/factories.py index 12aa48a7..94e6fb4e 100644 --- a/backend/src/openarchiefbeheer/accounts/tests/factories.py +++ b/backend/src/openarchiefbeheer/accounts/tests/factories.py @@ -3,14 +3,24 @@ import factory from factory.django import DjangoModelFactory +from ..models import Role + User = get_user_model() +class RoleFactory(DjangoModelFactory): + name = factory.Sequence(lambda n: f"Role {n}") + + class Meta: + model = Role + + class UserFactory(DjangoModelFactory): username = factory.Sequence(lambda n: f"user-{n}") first_name = factory.Faker("first_name") last_name = factory.Faker("last_name") password = factory.PostGenerationMethodCall("set_password", "password") + role = factory.SubFactory(RoleFactory) class Meta: model = User diff --git a/backend/src/openarchiefbeheer/api/tests/test_role_endpoints.py b/backend/src/openarchiefbeheer/api/tests/test_role_endpoints.py new file mode 100644 index 00000000..8c80ba70 --- /dev/null +++ b/backend/src/openarchiefbeheer/api/tests/test_role_endpoints.py @@ -0,0 +1,28 @@ +from rest_framework import status +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from openarchiefbeheer.accounts.tests.factories import UserFactory + + +class RoleEndpointTests(APITestCase): + def test_user_not_logged_in(self): + response = self.client.get(reverse("api:reviewers")) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_reviewers(self): + admin = UserFactory.create(is_superuser=True, role=None) + UserFactory.create_batch(2, role__can_review_destruction=True) + UserFactory.create_batch(2, role__can_review_destruction=False) + + self.client.force_authenticate(user=admin) + response = self.client.get(reverse("api:reviewers")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json() + + self.assertEqual(len(data), 2) + self.assertTrue(data[0]["role"]["canReviewDestruction"]) + self.assertTrue(data[1]["role"]["canReviewDestruction"]) diff --git a/backend/src/openarchiefbeheer/api/urls.py b/backend/src/openarchiefbeheer/api/urls.py index db6bb8dd..acaf2b80 100644 --- a/backend/src/openarchiefbeheer/api/urls.py +++ b/backend/src/openarchiefbeheer/api/urls.py @@ -6,6 +6,8 @@ SpectacularRedocView, ) +from openarchiefbeheer.accounts.api.views import ReviewersView + app_name = "api" urlpatterns = [ @@ -35,4 +37,5 @@ "openarchiefbeheer.api.authentication.urls", namespace="authentication" ), ), + path("v1/reviewers/", ReviewersView.as_view(), name="reviewers"), ]