Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
8 changes: 8 additions & 0 deletions jasmin_services/models/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 7 additions & 0 deletions jasmin_services/models/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions jasmin_services/templates/jasmin_services/service_details.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
<li class="breadcrumb-item active" aria-current="page">{{ service.name }}</li>
{% endblock %}

{% block notifications %}
{% endblock notifications %}

{% block content_header %}{{ block.super }}
<div class="row py-2">
<div class="col-md-12">
Expand Down Expand Up @@ -38,6 +41,29 @@
</div>
<div class="col-md-6">
{% block service_details_right_panel %}
{% block service_details_notifications %}
{% if messages %}
{% for message in messages %}
{% if 'success' in message.tags %}
<div class="alert alert-with-icon alert-success" role="alert">
<i class="fas fa-check-circle"></i> <span>{{ message }}</span>
</div>
{% elif 'error' in message.tags %}
<div class="alert alert-with-icon alert-danger" role="alert">
<i class="fas fa-exclamation-circle"></i> <span>{{ message }}</span>
</div>
{% elif 'warning' in message.tags %}
<div class="alert alert-with-icon alert-warning fs-4" role="alert">
<i class="fas fa-exclamation-triangle"></i> <span>{{ message }}</span>
</div>
{% else %}
<div class="alert alert-with-icon alert-info" role="alert">
<i class="fas fa-info-circle"></i> <span>{{ message }}</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endblock service_details_notifications %}
{% block service_access %}
{% if accesses %}
<div class="card">
Expand Down
85 changes: 85 additions & 0 deletions jasmin_services/tests/test_category.py
Original file line number Diff line number Diff line change
@@ -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)
151 changes: 151 additions & 0 deletions jasmin_services/tests/test_may_apply.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading