diff --git a/jasmin_services/migrations/0030_alter_behaviour_options_and_more.py b/jasmin_services/migrations/0030_alter_behaviour_options_and_more.py new file mode 100644 index 0000000..541773f --- /dev/null +++ b/jasmin_services/migrations/0030_alter_behaviour_options_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.13 on 2026-04-16 11:21 + +import django.db.models.deletion +import django_countries.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("jasmin_services", "0029_request_jasmin_serv_resulti_2f6892_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="category", + name="require_role_to_apply", + field=models.ForeignKey( + default=None, + help_text="Require uses have this role before they are able to apply for roles in this category.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="jasmin_services.role", + ), + ), + migrations.AlterField( + model_name="grant", + name="internal_comment", + field=models.TextField(blank=True, verbose_name="Internal notes"), + ), + migrations.AlterField( + model_name="role", + name="auto_accept", + field=models.BooleanField( + default=False, + help_text="Auto accepts all requested access giving users with a 1 year expiration date.", + ), + ), + migrations.AlterField( + model_name="service", + name="institution_countries", + field=django_countries.fields.CountryField( + blank=True, + help_text="Coutries a user's institute must be located to begin approval. Hold ctrl or cmd for mac to select multiple countries. Leave blank for any country.", + max_length=746, + multiple=True, + ), + ), + ] diff --git a/jasmin_services/models/category.py b/jasmin_services/models/category.py index 4fad9b6..8cd057c 100644 --- a/jasmin_services/models/category.py +++ b/jasmin_services/models/category.py @@ -29,5 +29,13 @@ class Meta: "then alphabetically by name within that.", ) + require_role_to_apply = models.ForeignKey( + "jasmin_services.Role", + null=True, + default=None, + help_text="Require uses have this role before they are able to apply for roles in this category.", + on_delete=models.SET_NULL, + ) + def __str__(self): return str(self.long_name) diff --git a/jasmin_services/models/role.py b/jasmin_services/models/role.py index 90b963c..3098a42 100644 --- a/jasmin_services/models/role.py +++ b/jasmin_services/models/role.py @@ -177,6 +177,13 @@ async def auser_may_apply(self, user): query = self._user_may_apply_query(user) return not await query.aexists() + def user_has_role(self, user): + return self.accesses.filter( + user=user, + grant__revoked=False, + grant__expires__gte=django.utils.timezone.localdate(), + ).exists() + def enable(self, user): """Enable this role for the given user.""" # During an import, disable all behaviours diff --git a/jasmin_services/templates/jasmin_services/service_details.html b/jasmin_services/templates/jasmin_services/service_details.html index 4acaa48..efd9abb 100644 --- a/jasmin_services/templates/jasmin_services/service_details.html +++ b/jasmin_services/templates/jasmin_services/service_details.html @@ -8,6 +8,9 @@ {% endblock %} +{% block notifications %} +{% endblock notifications %} + {% block content_header %}{{ block.super }}
@@ -38,6 +41,29 @@
{% block service_details_right_panel %} + {% block service_details_notifications %} + {% if messages %} + {% for message in messages %} + {% if 'success' in message.tags %} + + {% elif 'error' in message.tags %} + + {% elif 'warning' in message.tags %} + + {% else %} + + {% endif %} + {% endfor %} + {% endif %} + {% endblock service_details_notifications %} {% block service_access %} {% if accesses %}
diff --git a/jasmin_services/tests/test_category.py b/jasmin_services/tests/test_category.py new file mode 100644 index 0000000..a0c26e7 --- /dev/null +++ b/jasmin_services/tests/test_category.py @@ -0,0 +1,85 @@ +import django.contrib.auth +import django.contrib.messages.storage.cookie +import django.http +import django.test +import django.views.generic.base + +import jasmin_metadata.models +import jasmin_services.models +import jasmin_services.views.mixins + + +class CategoryRequireRoleOnDeleteTest(django.test.TestCase): + """Tests for the on_delete behaviour of Category.require_role_to_apply.""" + + class _TestView(jasmin_services.views.mixins.MayApplyMixin, django.views.generic.base.View): + def get(self, request, *args, **kwargs): + return django.http.HttpResponse("OK") + + def setUp(self): + self.user = django.contrib.auth.get_user_model().objects.create_user( + username="testuser", + ) + metadata_form = jasmin_metadata.models.Form.objects.create(name="test_form") + prerequisite_category = jasmin_services.models.Category.objects.create( + name="prerequisite_category", + long_name="Prerequisite Category", + position=1, + ) + prerequisite_service = jasmin_services.models.Service.objects.create( + category=prerequisite_category, + name="prerequisite_service", + summary="Prerequisite service", + description="", + ) + self.prerequisite_role = jasmin_services.models.Role.objects.create( + service=prerequisite_service, + name="prerequisite_role", + description="", + metadata_form=metadata_form, + ) + self.category = jasmin_services.models.Category.objects.create( + name="restricted_category", + long_name="Restricted Category", + position=2, + require_role_to_apply=self.prerequisite_role, + ) + self.restricted_service = jasmin_services.models.Service.objects.create( + category=self.category, + name="restricted_service", + summary="Restricted service", + description="", + ) + + def _dispatch(self, service): + request = django.test.RequestFactory().get("/") + request.user = self.user + request._messages = django.contrib.messages.storage.cookie.CookieStorage(request) + + view = self._TestView() + view.service = service + view.request = request + view.args = [] + view.kwargs = {} + return view.dispatch(request) + + def test_deleting_role_does_not_delete_category(self): + """Deleting the required role must not cascade-delete the category.""" + self.prerequisite_role.delete() + self.assertTrue( + jasmin_services.models.Category.objects.filter(pk=self.category.pk).exists() + ) + + def test_deleting_role_clears_require_role_to_apply(self): + """Deleting the required role sets require_role_to_apply to NULL on the category.""" + self.prerequisite_role.delete() + self.category.refresh_from_db() + self.assertIsNone(self.category.require_role_to_apply) + + def test_after_role_deletion_mixin_allows_access(self): + """Once the required role is deleted, all users should be able to apply.""" + self.prerequisite_role.delete() + # Fetch fresh from DB — the in-memory instance has category cached with the old FK value. + fresh_service = jasmin_services.models.Service.objects.get(pk=self.restricted_service.pk) + response = self._dispatch(fresh_service) + self.assertEqual(response.status_code, 200) diff --git a/jasmin_services/tests/test_may_apply.py b/jasmin_services/tests/test_may_apply.py new file mode 100644 index 0000000..1d78625 --- /dev/null +++ b/jasmin_services/tests/test_may_apply.py @@ -0,0 +1,151 @@ +import datetime as dt +from unittest import mock + +import django.contrib.auth +import django.contrib.messages +import django.contrib.messages.storage.cookie +import django.http +import django.test +import django.views.generic.base + +import jasmin_metadata.models +import jasmin_services.models +import jasmin_services.views.mixins + + +class MayApplyMixinTest(django.test.TestCase): + """Tests for MayApplyMixin.dispatch().""" + + class _TestView(jasmin_services.views.mixins.MayApplyMixin, django.views.generic.base.View): + def get(self, request, *args, **kwargs): + return django.http.HttpResponse("OK") + + def setUp(self): + self.user = django.contrib.auth.get_user_model().objects.create_user( + username="testuser", + ) + self.user.notify_if_not_exists = mock.Mock() + self.user.notify = mock.Mock() + metadata_form = jasmin_metadata.models.Form.objects.create(name="test_form") + + prerequisite_category = jasmin_services.models.Category.objects.create( + name="prerequisite_category", + long_name="Prerequisite Category", + position=1, + ) + self.prerequisite_service = jasmin_services.models.Service.objects.create( + category=prerequisite_category, + name="prerequisite_service", + summary="Prerequisite service", + description="", + ) + self.prerequisite_role = jasmin_services.models.Role.objects.create( + service=self.prerequisite_service, + name="prerequisite_role", + description="", + metadata_form=metadata_form, + ) + + restricted_category = jasmin_services.models.Category.objects.create( + name="restricted_category", + long_name="Restricted Category", + position=2, + require_role_to_apply=self.prerequisite_role, + ) + self.restricted_service = jasmin_services.models.Service.objects.create( + category=restricted_category, + name="restricted_service", + summary="Restricted service", + description="", + ) + + unrestricted_category = jasmin_services.models.Category.objects.create( + name="unrestricted_category", + long_name="Unrestricted Category", + position=3, + ) + self.unrestricted_service = jasmin_services.models.Service.objects.create( + category=unrestricted_category, + name="unrestricted_service", + summary="Unrestricted service", + description="", + ) + + def _dispatch(self, service): + """Dispatch a GET request to the test view for the given service.""" + request = django.test.RequestFactory().get("/") + request.user = self.user + request._messages = django.contrib.messages.storage.cookie.CookieStorage(request) + + view = self._TestView() + view.service = service + view.request = request + view.args = [] + view.kwargs = {} + return view.dispatch(request), request + + def test_no_require_role_to_apply_allows_access(self): + """When no role is required by the category, the mixin passes the request through.""" + response, _ = self._dispatch(self.unrestricted_service) + self.assertEqual(response.status_code, 200) + + def test_user_with_required_role_allows_access(self): + """A user who holds the required role is passed through by the mixin.""" + access = jasmin_services.models.Access.objects.create( + user=self.user, + role=self.prerequisite_role, + ) + jasmin_services.models.Grant.objects.create( + access=access, + granted_by="admin", + expires=dt.date.today() + dt.timedelta(days=365), + revoked=False, + ) + response, _ = self._dispatch(self.restricted_service) + self.assertEqual(response.status_code, 200) + + def test_user_without_required_role_is_redirected(self): + """A user who lacks the required role is redirected to that role's service detail page.""" + response, _ = self._dispatch(self.restricted_service) + self.assertEqual(response.status_code, 302) + self.assertIn("prerequisite_category", response["Location"]) + self.assertIn("prerequisite_service", response["Location"]) + + def test_user_without_required_role_receives_warning_message(self): + """The redirect is accompanied by a warning message naming both services.""" + response, request = self._dispatch(self.restricted_service) + messages = list(django.contrib.messages.get_messages(request)) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].level, django.contrib.messages.WARNING) + self.assertIn("prerequisite_service", str(messages[0])) + self.assertIn("restricted_service", str(messages[0])) + + def test_user_with_revoked_grant_is_redirected(self): + """A revoked grant does not satisfy the role requirement.""" + access = jasmin_services.models.Access.objects.create( + user=self.user, + role=self.prerequisite_role, + ) + jasmin_services.models.Grant.objects.create( + access=access, + granted_by="admin", + expires=dt.date.today() + dt.timedelta(days=365), + revoked=True, + ) + response, _ = self._dispatch(self.restricted_service) + self.assertEqual(response.status_code, 302) + + def test_user_with_expired_grant_is_redirected(self): + """An expired grant does not satisfy the role requirement.""" + access = jasmin_services.models.Access.objects.create( + user=self.user, + role=self.prerequisite_role, + ) + jasmin_services.models.Grant.objects.create( + access=access, + granted_by="admin", + expires=dt.date.today() - dt.timedelta(days=1), + revoked=False, + ) + response, _ = self._dispatch(self.restricted_service) + self.assertEqual(response.status_code, 302) diff --git a/jasmin_services/tests/test_role.py b/jasmin_services/tests/test_role.py new file mode 100644 index 0000000..fabfb74 --- /dev/null +++ b/jasmin_services/tests/test_role.py @@ -0,0 +1,125 @@ +import datetime as dt +from unittest import mock + +import django.contrib.auth +import django.test + +import jasmin_metadata.models +import jasmin_services.models + + +class RoleUserHasRoleTest(django.test.TestCase): + """Tests for Role.user_has_role().""" + + def setUp(self): + self.user = django.contrib.auth.get_user_model().objects.create_user( + username="testuser", + ) + self.user.notify_if_not_exists = mock.Mock() + self.user.notify = mock.Mock() + metadata_form = jasmin_metadata.models.Form.objects.create(name="test_form") + category = jasmin_services.models.Category.objects.create( + name="test_category", + long_name="Test Category", + position=1, + ) + service = jasmin_services.models.Service.objects.create( + category=category, + name="test_service", + summary="Test service", + description="", + ) + self.role = jasmin_services.models.Role.objects.create( + service=service, + name="test_role", + description="", + metadata_form=metadata_form, + ) + self.access = jasmin_services.models.Access.objects.create( + user=self.user, + role=self.role, + ) + + def test_user_with_active_grant_has_role(self): + """A non-revoked, non-expired grant means the user has the role.""" + jasmin_services.models.Grant.objects.create( + access=self.access, + granted_by="admin", + expires=dt.date.today() + dt.timedelta(days=365), + revoked=False, + ) + self.assertTrue(self.role.user_has_role(self.user)) + + def test_user_with_grant_expiring_today_has_role(self): + """Grant expiring today is still active (gte comparison).""" + jasmin_services.models.Grant.objects.create( + access=self.access, + granted_by="admin", + expires=dt.date.today(), + revoked=False, + ) + self.assertTrue(self.role.user_has_role(self.user)) + + def test_user_with_revoked_grant_does_not_have_role(self): + """A revoked grant, even with a future expiry, does not count.""" + jasmin_services.models.Grant.objects.create( + access=self.access, + granted_by="admin", + expires=dt.date.today() + dt.timedelta(days=365), + revoked=True, + ) + self.assertFalse(self.role.user_has_role(self.user)) + + def test_user_with_expired_grant_does_not_have_role(self): + """A grant that expired yesterday does not count.""" + jasmin_services.models.Grant.objects.create( + access=self.access, + granted_by="admin", + expires=dt.date.today() - dt.timedelta(days=1), + revoked=False, + ) + self.assertFalse(self.role.user_has_role(self.user)) + + def test_user_without_grant_does_not_have_role(self): + """An Access record with no associated Grant means the user does not have the role.""" + self.assertFalse(self.role.user_has_role(self.user)) + + def test_other_users_grant_does_not_count(self): + """An active grant belonging to a different user does not satisfy the check.""" + other_user = django.contrib.auth.get_user_model().objects.create_user( + username="otheruser", + ) + other_access = jasmin_services.models.Access.objects.create( + user=other_user, + role=self.role, + ) + jasmin_services.models.Grant.objects.create( + access=other_access, + granted_by="admin", + expires=dt.date.today() + dt.timedelta(days=365), + revoked=False, + ) + self.assertFalse(self.role.user_has_role(self.user)) + + def test_user_with_revoked_and_active_grants_has_role(self): + """An active grant counts even when a revoked grant also exists.""" + jasmin_services.models.Grant.objects.create( + access=self.access, + granted_by="admin", + expires=dt.date.today() + dt.timedelta(days=365), + revoked=True, + ) + jasmin_services.models.Grant.objects.create( + access=self.access, + granted_by="admin", + expires=dt.date.today() + dt.timedelta(days=365), + revoked=False, + ) + self.assertTrue(self.role.user_has_role(self.user)) + + def test_user_with_no_access_record_does_not_have_role(self): + """A user with no Access record for the role does not have it.""" + user_without_access = django.contrib.auth.get_user_model().objects.create_user( + username="noaccessuser", + ) + self.assertFalse(self.role.user_has_role(user_without_access)) diff --git a/jasmin_services/views/mixins.py b/jasmin_services/views/mixins.py index 54d947b..fa30088 100644 --- a/jasmin_services/views/mixins.py +++ b/jasmin_services/views/mixins.py @@ -4,6 +4,7 @@ import asgiref.sync import django.contrib.auth.mixins +import django.contrib.messages import django.http import django.urls from django.db.models import Q @@ -145,3 +146,27 @@ async def display_accesses(self, user, grants, requests, may_apply_override=None accesses = sorted(processed, key=lambda x: x.frontend["start"], reverse=True) return accesses + + +class MayApplyMixin: + def dispatch(self, request, *args, **kwargs): + req_role_to_apply = self.service.category.require_role_to_apply + + if (req_role_to_apply is not None) and (not req_role_to_apply.user_has_role(request.user)): + django.contrib.messages.add_message( + request, + django.contrib.messages.WARNING, + f"You must have been granted the {req_role_to_apply.service.name} service before you may apply for {self.service.category.name}/{self.service.name}.", + ) + + return django.http.HttpResponseRedirect( + django.urls.reverse( + "jasmin_services:service_details", + kwargs={ + "category": req_role_to_apply.service.category.name, + "service": req_role_to_apply.service.name, + }, + ), + ) + + return super().dispatch(request, *args, **kwargs) diff --git a/jasmin_services/views/role_apply.py b/jasmin_services/views/role_apply.py index a059f02..3405d4e 100644 --- a/jasmin_services/views/role_apply.py +++ b/jasmin_services/views/role_apply.py @@ -21,6 +21,7 @@ class RoleApplyView( django.contrib.auth.mixins.LoginRequiredMixin, mixins.WithServiceMixin, + mixins.MayApplyMixin, django.views.generic.edit.FormView, ): """Handle for ``///apply//``.