diff --git a/jasmin_services/admin/__init__.py b/jasmin_services/admin/__init__.py index fd0f903..39bb7ea 100644 --- a/jasmin_services/admin/__init__.py +++ b/jasmin_services/admin/__init__.py @@ -5,54 +5,39 @@ __author__ = "Matt Pryor" __copyright__ = "Copyright 2015 UK Science and Technology Facilities Council" -from datetime import date -from urllib.parse import urlparse - import django.shortcuts -from django import http from django.conf import settings from django.contrib import admin, messages -from django.contrib.admin.options import IS_POPUP_VAR -from django.contrib.admin.utils import quote from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core.mail import EmailMessage from django.db import models from django.shortcuts import redirect, render from django.template.loader import render_to_string -from django.urls import Resolver404, path, re_path, resolve, reverse +from django.urls import path, re_path from django.utils.safestring import mark_safe - -from jasmin_metadata.admin import HasMetadataModelAdmin -from jasmin_metadata.models import Form, Metadatum +from jasmin_metadata.models import Form from .. import models as service_models -from ..actions import ( - remind_pending, - send_expiry_notifications, - synchronise_service_access, -) -from ..forms import ( - AdminDecisionForm, - AdminGrantForm, - AdminRequestForm, - AdminRevokeForm, - admin_message_form_factory, -) +from ..forms import admin_message_form_factory from ..models import ( Access, Category, Grant, Request, - RequestState, Role, RoleObjectPermission, - Service, + Service ) from ..widgets import AdminGfkContentTypeWidget, AdminGfkObjectIdWidget # Load the admin for behaviours which are turned on. from . import behaviour # unimport:skip +from . import grant, request + +# Register admins from submodules. +admin.site.register(Grant, grant.GrantAdmin) +admin.site.register(Request, request.RequestAdmin) class GroupAdmin(admin.ModelAdmin): @@ -420,46 +405,6 @@ def has_module_permission(self, request): return False -class _ServiceFilter(admin.SimpleListFilter): - title = "Service" - parameter_name = "service_id" - - def lookups(self, request, model_admin): - # Fetch the services and the categories at once - services = Service.objects.all().select_related("category") - return tuple((s.pk, str(s)) for s in services) - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(access__role__service__pk=self.value()) - - -class _ExpiredListFilter(admin.SimpleListFilter): - title = "Expired" - parameter_name = "expired" - - def lookups(self, request, model_admin): - return (("1", "Yes"), ("0", "No")) - - def queryset(self, request, queryset): - if self.value() == "1": - return queryset.filter(expires__lt=date.today()) - elif self.value() == "0": - return queryset.filter(expires__gte=date.today()) - - -class _ActiveListFilter(admin.SimpleListFilter): - title = "Active" - parameter_name = "active" - - def lookups(self, request, model_admin): - return (("1", "Active only"),) - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter_active() - - class GrantInline(admin.TabularInline): model = Grant fields = ("active", "revoked", "expired", "expires") @@ -491,388 +436,3 @@ class AccessAdmin(admin.ModelAdmin): "user__last_name", "user__email", ) - - -@admin.register(Grant) -class GrantAdmin(HasMetadataModelAdmin): - list_display = ("access", "active", "revoked", "expired", "expires", "granted_at") - list_filter = ( - _ServiceFilter, - "access__role__name", - ("access__user", admin.RelatedOnlyFieldListFilter), - _ActiveListFilter, - "revoked", - _ExpiredListFilter, - ) - # This is expensive and unnecessary - show_full_result_count = False - search_fields = ( - "access__role__service__name", - "access__role__name", - "access__user__username", - "access__user__email", - "access__user__last_name", - ) - actions = ( - "synchronise_service_access", - "send_expiry_notifications", - "revoke_grants", - ) - list_select_related = ( - "access__role", - "access__role__service", - "access__role__service__category", - "access__user", - ) - # Allow "Save as new" for quick duplication of grants - save_as = True - - change_form_template = "admin/jasmin_services/grant/change_form.html" - - raw_id_fields = ( - "access", - "previous_grant", - ) - - def get_form(self, request, obj=None, change=None, **kwargs): - kwargs["form"] = AdminGrantForm - return super().get_form(request, obj=obj, change=change, **kwargs) - - def get_queryset(self, request): - # Annotate with information about active status - return super().get_queryset(request).annotate_active() - - def synchronise_service_access(self, request, queryset): - """ - Admin action that synchronises actual service access with the selected grants. - """ - synchronise_service_access(queryset) - - synchronise_service_access.short_description = "Synchronise service access" - - def send_expiry_notifications(self, request, queryset): - """ - Admin action that sends expiry notifications, where required, for the selected grants. - """ - send_expiry_notifications(queryset) - - send_expiry_notifications.short_description = "Send expiry notifications" - - def revoke_grants(self, request, queryset): - """ - Admin action that revokes the selected grants. - """ - selected = queryset.values_list("pk", flat=True) - selected_ids = "_".join(str(pk) for pk in selected) - - return redirect( - reverse( - "admin:jasmin_services_bulk_revoke", - kwargs={"ids": selected_ids}, - current_app=self.admin_site.name, - ) - ) - - revoke_grants.short_description = "Revoke selected grants" - - def active(self, obj): - """ - Returns ``True`` if the given grant is the active grant for the - service/role/user combination. - """ - return obj.active - - active.boolean = True - - def expired(self, obj): - """ - Returns ``True`` if the given grant has expired, ``False`` otherwise. - """ - return obj.expired - - expired.boolean = True - - def get_referring_request(self, request): - """ - Tries to get the request which referred the user to the grant page. - """ - # If the request is not a GET request, don't bother - if request.method != "GET": - return None - referrer = request.META.get("HTTP_REFERER") - if not referrer: - return None - req_change_url_name = "{}_{}_change".format( - Request._meta.app_label, Request._meta.model_name - ) - try: - match = resolve(urlparse(referrer).path) - if match.url_name == req_change_url_name: - return Request.objects.get(pk=match.args[0]) - except (ValueError, Resolver404, Request.DoesNotExist): - # These are expected errors - return None - - def get_changeform_initial_data(self, request): - initial = super().get_changeform_initial_data(request) - initial["granted_by"] = request.user.username - # If there is data from a referring request to populate, do that - referring = self.get_referring_request(request) - if referring: - initial.update(role=referring.access.role, user=referring.access.user) - return initial - - def add_view(self, request, form_url="", extra_context=None): - # When adding a grant, add the ID of the referring request to the context if present - if request.method == "GET": - referring = self.get_referring_request(request) - if referring: - extra_context = extra_context or {} - extra_context.update(from_request=referring.pk) - elif "_from_request" in request.POST: - extra_context = extra_context or {} - extra_context.update(from_request=request.POST["_from_request"]) - return super().add_view(request, form_url, extra_context) - - def get_metadata_form_class(self, request, obj=None): - if not obj: - return None - try: - return obj.access.role.metadata_form_class - except Role.DoesNotExist: - return None - - def get_metadata_form_initial_data(self, request, obj): - """ - Gets the initial data for the metadata form. By default, this just - returns the metadata currently attached to the object. - """ - if obj.pk: - return super().get_metadata_form_initial_data(request, obj) - # If the object has not been saved, try to get initial metadata from a - # referring request - if "_from_request" in request.POST: - referring = Request.objects.filter(pk=request.POST["_from_request"]).first() - else: - referring = self.get_referring_request(request) - if referring: - ctype = ContentType.objects.get_for_model(referring) - metadata = Metadatum.objects.filter(content_type=ctype, object_id=referring.pk) - return {d.key: d.value for d in metadata.all()} - return super().get_metadata_form_initial_data(request, obj) - - def get_urls(self): - return [ - re_path( - r"^bulk_revoke/(?P[0-9_]+)/$", - self.admin_site.admin_view(self.bulk_revoke), - name="jasmin_services_bulk_revoke", - ), - ] + super().get_urls() - - def bulk_revoke(self, request, ids): - ids = ids.split("_") - if request.method == "POST": - form = AdminRevokeForm(data=request.POST) - if form.is_valid(): - user_reason = form.cleaned_data["user_reason"] - internal_reason = form.cleaned_data["internal_reason"] - - Grant.objects.filter(pk__in=ids).update( - revoked=True, - user_reason=user_reason, - internal_reason=internal_reason, - ) - return redirect(f"{self.admin_site.name}:jasmin_services_grant_changelist") - else: - form = AdminRevokeForm() - context = { - "title": "Bulk Revoke Grants", - "form": form, - "opts": self.model._meta, - "media": self.media + form.media, - } - context.update(self.admin_site.each_context(request)) - request.current_app = self.admin_site.name - return render(request, "admin/jasmin_services/grant/bulk_revoke.html", context) - - -class _StateListFilter(admin.SimpleListFilter): - title = "State" - parameter_name = "state" - - def lookups(self, request, model_admin): - return RequestState.choices() - - def queryset(self, request, queryset): - value = self.value() - if value in RequestState.all(): - return queryset.filter(state=value) - - -@admin.register(Request) -class RequestAdmin(HasMetadataModelAdmin): - list_display = ( - "role_link", - "access", - "active", - "state_html", - "next_request", - "previous_grant", - "requested_at", - ) - list_filter = ( - _ServiceFilter, - "access__role__name", - ("access__user", admin.RelatedOnlyFieldListFilter), - _ActiveListFilter, - _StateListFilter, - ) - # This is expensive and unnecessary - show_full_result_count = False - search_fields = ( - "access__role__service__name", - "access__role__name", - "access__user__username", - "access__user__email", - "access__user__last_name", - ) - actions = ("remind_pending",) - raw_id_fields = ( - "previous_request", - "previous_grant", - ) - readonly_fields = ( - "requested_at", - "resulting_grant", - ) - - def get_form(self, request, obj=None, change=None, **kwargs): - kwargs["form"] = AdminRequestForm - return super().get_form(request, obj=obj, change=change, **kwargs) - - def get_queryset(self, request): - # Annotate with information about active status - return super().get_queryset(request).annotate_active() - - def remind_pending(self, request, queryset): - """ - Admin action that sends reminders for requests that have been pending for - too long. - """ - remind_pending(queryset) - - remind_pending.short_description = "Send pending reminders" - - def role_link(self, obj): - if obj.active and obj.state == RequestState.PENDING: - url = reverse( - "admin:jasmin_services_request_decide", - args=(quote(obj.pk),), - current_app=self.admin_site.name, - ) - else: - url = reverse( - "admin:jasmin_services_request_change", - args=(quote(obj.pk),), - current_app=self.admin_site.name, - ) - return mark_safe('{}'.format(url, obj.access.role)) - - role_link.short_description = "Service" - - def state_html(self, obj): - if obj.state == RequestState.PENDING: - return "PENDING" - elif obj.state == RequestState.APPROVED: - return 'APPROVED' - elif obj.incomplete: - return mark_safe('INCOMPLETE') - else: - return mark_safe('REJECTED') - - state_html.short_description = "State" - - def active(self, obj): - """ - Returns ``True`` if the given request is the active request for the - service/role/user combination. - """ - return obj.active - - active.boolean = True - - def get_metadata_form_class(self, request, obj=None): - if not obj: - return None - try: - return obj.access.role.metadata_form_class - except Role.DoesNotExist: - return None - - def get_changeform_initial_data(self, request): - initial = super().get_changeform_initial_data(request) - initial["requested_by"] = request.user.username - return initial - - def get_urls(self): - return [ - re_path( - r"^(.+)/decide/$", - self.admin_site.admin_view(self.decide_request), - name="jasmin_services_request_decide", - ), - ] + super().get_urls() - - def decide_request(self, request, pk): - if not self.has_change_permission(request): - raise PermissionDenied - # Try to find the specified request amongst the pending, active requests - try: - pending = Request.objects.filter(state=RequestState.PENDING).filter_active().get(pk=pk) - except Request.DoesNotExist: - raise http.Http404(f"Request with primary key {pk} does not exist") - # Process the form if this is a POST request, otherwise just set it up - if request.method == "POST": - form = AdminDecisionForm(pending, request.user, data=request.POST) - if form.is_valid(): - form.save() - self.message_user(request, "Decision made on request", messages.SUCCESS) - return redirect( - "{}:jasmin_services_request_changelist".format(self.admin_site.name) - ) - else: - form = AdminDecisionForm(pending, request.user) - # If the user requesting access has an active grant, find it - previous_grant = pending.previous_grant - - grants = Grant.objects.filter(access=pending.access).filter_active() - - # Find all the rejected requests for this request chain. - rejected = Request.objects.filter( - access=pending.access, - state=RequestState.REJECTED, - previous_grant=pending.previous_grant, - ).order_by("requested_at") - - context = { - "title": "Decide Service Request", - "form": form, - "is_popup": (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET), - "add": True, - "change": False, - "has_delete_permission": False, - "has_change_permission": True, - "has_absolute_url": False, - "opts": self.model._meta, - "original": pending, - "save_as": False, - "show_save": True, - "media": self.media + form.media, - "rejected": rejected, - "previous_grant": previous_grant, - "grants": grants, - } - context.update(self.admin_site.each_context(request)) - request.current_app = self.admin_site.name - return render(request, "admin/jasmin_services/request/decide.html", context) diff --git a/jasmin_services/admin/filters.py b/jasmin_services/admin/filters.py new file mode 100644 index 0000000..4f490f4 --- /dev/null +++ b/jasmin_services/admin/filters.py @@ -0,0 +1,58 @@ +from datetime import date + +from django.contrib import admin + +from ..models import RequestState, Service + + +class ServiceFilter(admin.SimpleListFilter): + title = "Service" + parameter_name = "service_id" + + def lookups(self, request, model_admin): + # Fetch the services and the categories at once + services = Service.objects.all().select_related("category") + return tuple((s.pk, str(s)) for s in services) + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(access__role__service__pk=self.value()) + + +class ActiveListFilter(admin.SimpleListFilter): + title = "Active" + parameter_name = "active" + + def lookups(self, request, model_admin): + return (("1", "Active only"),) + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter_active() + + +class ExpiredListFilter(admin.SimpleListFilter): + title = "Expired" + parameter_name = "expired" + + def lookups(self, request, model_admin): + return (("1", "Yes"), ("0", "No")) + + def queryset(self, request, queryset): + if self.value() == "1": + return queryset.filter(expires__lt=date.today()) + elif self.value() == "0": + return queryset.filter(expires__gte=date.today()) + + +class StateListFilter(admin.SimpleListFilter): + title = "State" + parameter_name = "state" + + def lookups(self, request, model_admin): + return RequestState.choices() + + def queryset(self, request, queryset): + value = self.value() + if value in RequestState.all(): + return queryset.filter(state=value) diff --git a/jasmin_services/admin/grant.py b/jasmin_services/admin/grant.py new file mode 100644 index 0000000..2b615f6 --- /dev/null +++ b/jasmin_services/admin/grant.py @@ -0,0 +1,217 @@ +from urllib.parse import urlparse + +from django.contrib import admin +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import redirect, render +from django.urls import Resolver404, re_path, resolve, reverse + +from jasmin_metadata.admin import HasMetadataModelAdmin +from jasmin_metadata.models import Metadatum + +from ..actions import send_expiry_notifications, synchronise_service_access +from ..forms import AdminGrantForm, AdminRevokeForm +from ..models import Grant, Request, Role +from . import filters + + +class GrantAdmin(HasMetadataModelAdmin): + list_display = ("access", "active", "revoked", "expired", "expires", "granted_at") + list_filter = ( + filters.ServiceFilter, + "access__role__name", + ("access__user", admin.RelatedOnlyFieldListFilter), + filters.ActiveListFilter, + "revoked", + filters.ExpiredListFilter, + ) + # This is expensive and unnecessary + show_full_result_count = False + search_fields = ( + "access__role__service__name", + "access__role__name", + "access__user__username", + "access__user__email", + "access__user__last_name", + ) + actions = ( + "synchronise_service_access", + "send_expiry_notifications", + "revoke_grants", + ) + list_select_related = ( + "access__role", + "access__role__service", + "access__role__service__category", + "access__user", + ) + # Allow "Save as new" for quick duplication of grants + save_as = True + + change_form_template = "admin/jasmin_services/grant/change_form.html" + + raw_id_fields = ( + "access", + "previous_grant", + ) + + def get_form(self, request, obj=None, change=None, **kwargs): + kwargs["form"] = AdminGrantForm + return super().get_form(request, obj=obj, change=change, **kwargs) + + def get_queryset(self, request): + # Annotate with information about active status + return super().get_queryset(request).annotate_active() + + def synchronise_service_access(self, request, queryset): + """ + Admin action that synchronises actual service access with the selected grants. + """ + synchronise_service_access(queryset) + + synchronise_service_access.short_description = "Synchronise service access" + + def send_expiry_notifications(self, request, queryset): + """ + Admin action that sends expiry notifications, where required, for the selected grants. + """ + send_expiry_notifications(queryset) + + send_expiry_notifications.short_description = "Send expiry notifications" + + def revoke_grants(self, request, queryset): + """ + Admin action that revokes the selected grants. + """ + selected = queryset.values_list("pk", flat=True) + selected_ids = "_".join(str(pk) for pk in selected) + + return redirect( + reverse( + "admin:jasmin_services_bulk_revoke", + kwargs={"ids": selected_ids}, + current_app=self.admin_site.name, + ) + ) + + revoke_grants.short_description = "Revoke selected grants" + + def active(self, obj): + """ + Returns ``True`` if the given grant is the active grant for the + service/role/user combination. + """ + return obj.active + + active.boolean = True + + def expired(self, obj): + """ + Returns ``True`` if the given grant has expired, ``False`` otherwise. + """ + return obj.expired + + expired.boolean = True + + def get_referring_request(self, request): + """ + Tries to get the request which referred the user to the grant page. + """ + # If the request is not a GET request, don't bother + if request.method != "GET": + return None + referrer = request.META.get("HTTP_REFERER") + if not referrer: + return None + req_change_url_name = "{}_{}_change".format( + Request._meta.app_label, Request._meta.model_name + ) + try: + match = resolve(urlparse(referrer).path) + if match.url_name == req_change_url_name: + return Request.objects.get(pk=match.args[0]) + except (ValueError, Resolver404, Request.DoesNotExist): + # These are expected errors + return None + + def get_changeform_initial_data(self, request): + initial = super().get_changeform_initial_data(request) + initial["granted_by"] = request.user.username + # If there is data from a referring request to populate, do that + referring = self.get_referring_request(request) + if referring: + initial.update(role=referring.access.role, user=referring.access.user) + return initial + + def add_view(self, request, form_url="", extra_context=None): + # When adding a grant, add the ID of the referring request to the context if present + if request.method == "GET": + referring = self.get_referring_request(request) + if referring: + extra_context = extra_context or {} + extra_context.update(from_request=referring.pk) + elif "_from_request" in request.POST: + extra_context = extra_context or {} + extra_context.update(from_request=request.POST["_from_request"]) + return super().add_view(request, form_url, extra_context) + + def get_metadata_form_class(self, request, obj=None): + if not obj: + return None + try: + return obj.access.role.metadata_form_class + except Role.DoesNotExist: + return None + + def get_metadata_form_initial_data(self, request, obj): + """ + Gets the initial data for the metadata form. By default, this just + returns the metadata currently attached to the object. + """ + if obj.pk: + return super().get_metadata_form_initial_data(request, obj) + # If the object has not been saved, try to get initial metadata from a + # referring request + if "_from_request" in request.POST: + referring = Request.objects.filter(pk=request.POST["_from_request"]).first() + else: + referring = self.get_referring_request(request) + if referring: + ctype = ContentType.objects.get_for_model(referring) + metadata = Metadatum.objects.filter(content_type=ctype, object_id=referring.pk) + return {d.key: d.value for d in metadata.all()} + return super().get_metadata_form_initial_data(request, obj) + + def get_urls(self): + return [ + re_path( + r"^bulk_revoke/(?P[0-9_]+)/$", + self.admin_site.admin_view(self.bulk_revoke), + name="jasmin_services_bulk_revoke", + ), + ] + super().get_urls() + + def bulk_revoke(self, request, ids): + ids = ids.split("_") + if request.method == "POST": + form = AdminRevokeForm(data=request.POST) + if form.is_valid(): + user_reason = form.cleaned_data["user_reason"] + internal_reason = form.cleaned_data["internal_reason"] + + Grant.objects.filter(pk__in=ids).update( + revoked=True, + user_reason=user_reason, + internal_reason=internal_reason, + ) + return redirect(f"{self.admin_site.name}:jasmin_services_grant_changelist") + else: + form = AdminRevokeForm() + context = { + "title": "Bulk Revoke Grants", + "form": form, + "opts": self.model._meta, + "media": self.media + form.media, + } + context.update(self.admin_site.each_context(request)) + request.current_app = self.admin_site.name + return render(request, "admin/jasmin_services/grant/bulk_revoke.html", context) diff --git a/jasmin_services/admin/request.py b/jasmin_services/admin/request.py new file mode 100644 index 0000000..30050b3 --- /dev/null +++ b/jasmin_services/admin/request.py @@ -0,0 +1,125 @@ +import django.contrib.auth.views +from django import http +from django.contrib import admin, messages +from django.contrib.admin.options import IS_POPUP_VAR +from django.contrib.admin.utils import quote +from django.shortcuts import redirect, render +from django.urls import re_path, reverse, reverse_lazy +from django.utils.safestring import mark_safe + +from jasmin_metadata.admin import HasMetadataModelAdmin + +from ..actions import remind_pending +from ..forms import AdminDecisionForm, AdminRequestForm +from ..models import Grant, Request, RequestState, Role + +# Load the admin for behaviours which are turned on. +from . import behaviour # unimport:skip +from . import filters, request + + +class RequestAdmin(HasMetadataModelAdmin): + list_display = ( + "access", + "decide_link", + "active", + "state_html", + "next_request", + "previous_grant", + "requested_at", + ) + list_display_links = None + list_filter = ( + filters.ServiceFilter, + "access__role__name", + ("access__user", admin.RelatedOnlyFieldListFilter), + filters.ActiveListFilter, + filters.StateListFilter, + ) + # This is expensive and unnecessary + show_full_result_count = False + search_fields = ( + "access__role__service__name", + "access__role__name", + "access__user__username", + "access__user__email", + "access__user__last_name", + ) + actions = ("remind_pending",) + raw_id_fields = ( + "previous_request", + "previous_grant", + ) + readonly_fields = ( + "requested_at", + "resulting_grant", + ) + + def get_form(self, request, obj=None, change=None, **kwargs): + kwargs["form"] = AdminRequestForm + return super().get_form(request, obj=obj, change=change, **kwargs) + + def get_queryset(self, request): + # Annotate with information about active status + return super().get_queryset(request).annotate_active() + + def remind_pending(self, request, queryset): + """ + Admin action that sends reminders for requests that have been pending for + too long. + """ + remind_pending(queryset) + + remind_pending.short_description = "Send pending reminders" + + def decide_link(self, obj): + if obj.active and obj.state == RequestState.PENDING: + url = reverse( + "jasmin_services:request_decide", + kwargs={"pk": quote(obj.pk)}, + ) + here = reverse("admin:jasmin_services_request_changelist") + return mark_safe(f'Decide') + else: + url = reverse( + "admin:jasmin_services_request_change", + args=(quote(obj.pk),), + current_app=self.admin_site.name, + ) + return mark_safe(f'Edit') + + decide_link.short_description = "Actions" + + def state_html(self, obj): + if obj.state == RequestState.PENDING: + return "PENDING" + elif obj.state == RequestState.APPROVED: + return mark_safe('APPROVED') + elif obj.incomplete: + return mark_safe('INCOMPLETE') + else: + return mark_safe('REJECTED') + + state_html.short_description = "State" + + def active(self, obj): + """ + Returns ``True`` if the given request is the active request for the + service/role/user combination. + """ + return obj.active + + active.boolean = True + + def get_metadata_form_class(self, request, obj=None): + if not obj: + return None + try: + return obj.access.role.metadata_form_class + except Role.DoesNotExist: + return None + + def get_changeform_initial_data(self, request): + initial = super().get_changeform_initial_data(request) + initial["requested_by"] = request.user.username + return initial diff --git a/jasmin_services/forms/__init__.py b/jasmin_services/forms/__init__.py index 24bdf60..434bf21 100644 --- a/jasmin_services/forms/__init__.py +++ b/jasmin_services/forms/__init__.py @@ -11,7 +11,6 @@ import django.contrib.auth import django.utils.encoding -from dateutil.relativedelta import relativedelta from django import forms from django.conf import settings from django.contrib.admin.widgets import AdminDateWidget, FilteredSelectMultiple @@ -21,7 +20,8 @@ from django.utils.safestring import mark_safe from markdown_deux.templatetags.markdown_deux_tags import markdown_allowed -from ..models import Access, Grant, Request, RequestState, Role +from ..models import Access, Grant, Request, Role +from .decision_form import DecisionForm # unimport: skip def message_form_factory(sender, *roles): @@ -147,154 +147,6 @@ def validate_grant_username(value): raise ValidationError(f"user ({value}) does not exist.") -class DecisionForm(forms.Form): - """ - Form for making a decision on a request. - """ - - # Constants defining options for the quick expiry selection - EXPIRES_SIX_MONTHS = 1 - EXPIRES_ONE_YEAR = 2 - EXPIRES_TWO_YEARS = 3 - EXPIRES_THREE_YEARS = 4 - EXPIRES_FIVE_YEARS = 5 - EXPIRES_TEN_YEARS = 6 - EXPIRES_CUSTOM = 7 - - state = forms.TypedChoiceField( - label="Decision", - choices=[ - (None, "---------"), - ("APPROVED", "APPROVED"), - ("INCOMPLETE", "INCOMPLETE"), - ("REJECTED", "REJECTED"), - ], - coerce=str, - empty_value=None, - ) - expires = forms.TypedChoiceField( - label="Expiry date", - help_text="Pick a duration from the dropdown list, or pick a custom expiry date", - required=False, - choices=[ - (0, "---------"), - (EXPIRES_SIX_MONTHS, "Six months from now"), - (EXPIRES_ONE_YEAR, "One year from now"), - (EXPIRES_TWO_YEARS, "Two years from now"), - (EXPIRES_THREE_YEARS, "Three years from now"), - (EXPIRES_FIVE_YEARS, "Five years from now"), - (EXPIRES_TEN_YEARS, "Ten years from now"), - (EXPIRES_CUSTOM, "Custom expiry date"), - ], - coerce=int, - empty_value=0, - ) - expires_custom = forms.DateField( - label="Custom expiry date", - required=False, - input_formats=["%Y-%m-%d", "%d/%m/%Y"], - widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}), - ) - user_reason = forms.CharField( - label="Reason for rejection (user)", - required=False, - widget=forms.Textarea(attrs={"rows": 5}), - help_text=mark_safe(markdown_allowed()), - ) - internal_reason = forms.CharField( - label="Reason for rejection (internal)", - required=False, - widget=forms.Textarea(attrs={"rows": 5}), - help_text=mark_safe(markdown_allowed()), - ) - - def __init__(self, request, approver, *args, **kwargs): - self._request = request - self._approver = approver - super().__init__(*args, **kwargs) - - def clean_state(self): - state = self.cleaned_data.get("state") - if state is None: - raise ValidationError("This field is required") - return state - - def clean_expires(self): - state = self.cleaned_data.get("state") - expires = self.cleaned_data.get("expires") - if state == "APPROVED" and not expires: - raise ValidationError("Please give an expiry date for access") - return expires - - def clean_expires_custom(self): - state = self.cleaned_data.get("state") - expires = self.cleaned_data.get("expires") - expires_custom = self.cleaned_data.get("expires_custom") - if state == "APPROVED" and expires == self.EXPIRES_CUSTOM and not expires_custom: - raise ValidationError("Please give an expiry date for access") - if expires_custom and expires_custom < date.today(): - raise ValidationError("Expiry date must be in the future") - return expires_custom - - def clean_user_reason(self): - state = self.cleaned_data.get("state") - user_reason = self.cleaned_data.get("user_reason") - if state != "APPROVED" and not user_reason: - raise ValidationError("Please give a reason for rejection or incompletion") - return user_reason - - def save(self): - # Update the request from the form - if self.cleaned_data["state"] == "APPROVED": - # Get the expiry date - expires = self.cleaned_data["expires"] - if expires == self.EXPIRES_SIX_MONTHS: - expires_date = date.today() + relativedelta(months=6) - elif expires == self.EXPIRES_ONE_YEAR: - expires_date = date.today() + relativedelta(years=1) - elif expires == self.EXPIRES_TWO_YEARS: - expires_date = date.today() + relativedelta(years=2) - elif expires == self.EXPIRES_THREE_YEARS: - expires_date = date.today() + relativedelta(years=3) - elif expires == self.EXPIRES_FIVE_YEARS: - expires_date = date.today() + relativedelta(years=5) - elif expires == self.EXPIRES_TEN_YEARS: - expires_date = date.today() + relativedelta(years=10) - else: - expires_date = self.cleaned_data["expires_custom"] - self._request.state = RequestState.APPROVED - # If the request has a previous_grant create a new grant - # and link with the old grant - previous_grant = self._request.previous_grant - if previous_grant: - self._request.resulting_grant = Grant.objects.create( - access=self._request.access, - granted_by=self._approver.username, - expires=expires_date, - previous_grant=previous_grant, - ) - else: - # Else create the access if it does not already exist and - # then create the new grant - access, _ = Access.objects.get_or_create( - user=self._request.access.user, role=self._request.access.role - ) - self._request.resulting_grant = Grant.objects.create( - access=access, - granted_by=self._approver.username, - expires=expires_date, - ) - # Copy the metadata from the request to the grant - self._request.copy_metadata_to(self._request.resulting_grant) - else: - self._request.state = RequestState.REJECTED - self._request.incomplete = True if self.cleaned_data["state"] == "INCOMPLETE" else False - self._request.user_reason = self.cleaned_data["user_reason"] - self._request.internal_reason = self.cleaned_data["internal_reason"] - self._request.save() - return self._request - - class GrantReviewForm(forms.Form): """ Form for revoking a grant. diff --git a/jasmin_services/forms/decision_form.py b/jasmin_services/forms/decision_form.py new file mode 100644 index 0000000..3bf9a24 --- /dev/null +++ b/jasmin_services/forms/decision_form.py @@ -0,0 +1,171 @@ +from datetime import date + +from dateutil.relativedelta import relativedelta +from django import forms +from django.core.exceptions import ValidationError +from django.utils.safestring import mark_safe +from markdown_deux.templatetags.markdown_deux_tags import markdown_allowed + +from ..models import Access, Grant, RequestState + + +class DecisionForm(forms.Form): + """Form for making a decision on a request.""" + + # Constants defining options for the quick expiry selection + EXPIRES_SIX_MONTHS = 1 + EXPIRES_ONE_YEAR = 2 + EXPIRES_TWO_YEARS = 3 + EXPIRES_THREE_YEARS = 4 + EXPIRES_FIVE_YEARS = 5 + EXPIRES_TEN_YEARS = 6 + EXPIRES_CUSTOM = 7 + + state = forms.TypedChoiceField( + label="Decision", + choices=[ + (None, "---------"), + ("APPROVED", "APPROVED"), + ("INCOMPLETE", "INCOMPLETE"), + ("REJECTED", "REJECTED"), + ], + coerce=str, + empty_value=None, + required=False, + ) + expires = forms.TypedChoiceField( + label="Expiry date", + help_text="Pick a duration from the dropdown list, or pick a custom expiry date", + required=False, + choices=[ + (0, "---------"), + (EXPIRES_SIX_MONTHS, "Six months from now"), + (EXPIRES_ONE_YEAR, "One year from now"), + (EXPIRES_TWO_YEARS, "Two years from now"), + (EXPIRES_THREE_YEARS, "Three years from now"), + (EXPIRES_FIVE_YEARS, "Five years from now"), + (EXPIRES_TEN_YEARS, "Ten years from now"), + (EXPIRES_CUSTOM, "Custom expiry date"), + ], + coerce=int, + empty_value=0, + ) + expires_custom = forms.DateField( + label="Custom expiry date", + required=False, + input_formats=["%Y-%m-%d", "%d/%m/%Y"], + widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}), + ) + user_reason = forms.CharField( + label="Reason for rejection (user)", + required=False, + widget=forms.Textarea(attrs={"rows": 5}), + help_text=mark_safe(markdown_allowed()), + ) + internal_reason = forms.CharField( + label="Reason for rejection (internal)", + required=False, + widget=forms.Textarea(attrs={"rows": 5}), + help_text=mark_safe(markdown_allowed()), + ) + + def __init__(self, request, approver, *args, **kwargs): + self._request = request + self._approver = approver + super().__init__(*args, **kwargs) + + if self._approver.is_staff: + self.fields["internal_comment"] = forms.CharField( + label="Internal comment (shown only to CEDA staff)", + required=False, + widget=forms.Textarea(attrs={"rows": 5}), + help_text=mark_safe(markdown_allowed()), + ) + + def clean_state(self): + state = self.cleaned_data.get("state") + if (not self._approver.is_staff) and (state is None): + raise ValidationError("This field is required") + return state + + def clean_expires(self): + state = self.cleaned_data.get("state") + expires = self.cleaned_data.get("expires") + if state == "APPROVED" and not expires: + raise ValidationError("Please give an expiry date for access") + return expires + + def clean_expires_custom(self): + state = self.cleaned_data.get("state") + expires = self.cleaned_data.get("expires") + expires_custom = self.cleaned_data.get("expires_custom") + if state == "APPROVED" and expires == self.EXPIRES_CUSTOM and not expires_custom: + raise ValidationError("Please give an expiry date for access") + if expires_custom and expires_custom < date.today(): + raise ValidationError("Expiry date must be in the future") + return expires_custom + + def clean_user_reason(self): + state = self.cleaned_data.get("state") + user_reason = self.cleaned_data.get("user_reason") + if (state is not None) and (state != "APPROVED") and (not user_reason): + raise ValidationError( + "Please give a reason for rejection or incompletion", code="no_user_reason" + ) + return user_reason + + def save(self): + # Update the request from the form + if self.cleaned_data["state"] == "APPROVED": + # Get the expiry date + expires = self.cleaned_data["expires"] + if expires == self.EXPIRES_SIX_MONTHS: + expires_date = date.today() + relativedelta(months=6) + elif expires == self.EXPIRES_ONE_YEAR: + expires_date = date.today() + relativedelta(years=1) + elif expires == self.EXPIRES_TWO_YEARS: + expires_date = date.today() + relativedelta(years=2) + elif expires == self.EXPIRES_THREE_YEARS: + expires_date = date.today() + relativedelta(years=3) + elif expires == self.EXPIRES_FIVE_YEARS: + expires_date = date.today() + relativedelta(years=5) + elif expires == self.EXPIRES_TEN_YEARS: + expires_date = date.today() + relativedelta(years=10) + else: + expires_date = self.cleaned_data["expires_custom"] + self._request.state = RequestState.APPROVED + # If the request has a previous_grant create a new grant + # and link with the old grant + previous_grant = self._request.previous_grant + if previous_grant: + self._request.resulting_grant = Grant.objects.create( + access=self._request.access, + granted_by=self._approver.username, + expires=expires_date, + previous_grant=previous_grant, + ) + else: + # Else create the access if it does not already exist and + # then create the new grant + access, _ = Access.objects.get_or_create( + user=self._request.access.user, role=self._request.access.role + ) + self._request.resulting_grant = Grant.objects.create( + access=access, + granted_by=self._approver.username, + expires=expires_date, + ) + # Copy the metadata from the request to the grant + self._request.copy_metadata_to(self._request.resulting_grant) + if self.cleaned_data["state"] in ["INCOMPLETE", "REJECTED"]: + self._request.state = RequestState.REJECTED + self._request.incomplete = True if self.cleaned_data["state"] == "INCOMPLETE" else False + self._request.user_reason = self.cleaned_data["user_reason"] + self._request.internal_reason = self.cleaned_data["internal_reason"] + + # Set the internal comment if the user is staff. + if self._approver.is_staff and self.cleaned_data.get("internal_comment", False): + self._request.internal_comment = self.cleaned_data["internal_comment"] + + self._request.save() + return self._request diff --git a/jasmin_services/models/request.py b/jasmin_services/models/request.py index 08c726e..7547dc6 100644 --- a/jasmin_services/models/request.py +++ b/jasmin_services/models/request.py @@ -170,11 +170,13 @@ class Meta: help_text=markdown_allowed(), ) - # Add a field for an internal comment about the grant. - internal_comment = models.TextField(blank=True, verbose_name="Internal nodes") + # Add a field for an internal comment about the request + # This is only shown to the CEDA team. + internal_comment = models.TextField(blank=True, verbose_name="Internal notes") def __str__(self): - return "{} : {}".format(self.access, "INCOMPETE" if self.incomplete else self.state) + state = "INCOMPLETE" if self.incomplete else self.state + return f"{self.access} : {state}" @property def status(self): diff --git a/jasmin_services/notifications.py b/jasmin_services/notifications.py index a99000d..fb3b513 100644 --- a/jasmin_services/notifications.py +++ b/jasmin_services/notifications.py @@ -101,7 +101,7 @@ def notify_approvers(instance): else: # If there are no approvers, post a message to Slack link = settings.BASE_URL + reverse( - "admin:jasmin_services_request_decide", args=(instance.pk,) + "jasmin_services:request_decide", kwargs={"pk": instance.pk} ) httpx.post( settings.SLACK_WEBHOOK, diff --git a/jasmin_services/static/jasmin_services/js/request_decide.js b/jasmin_services/static/jasmin_services/js/request_decide.js index dce8df1..3a3a753 100644 --- a/jasmin_services/static/jasmin_services/js/request_decide.js +++ b/jasmin_services/static/jasmin_services/js/request_decide.js @@ -5,14 +5,18 @@ var YES = 'APPROVED', NO = 'REJECTED', NO_INCOMPLETE = 'INCOMPLETE', CUSTOM_DATE var toggle_fields = function() { var state = $('[name="state"]').val(); var expires = $('[name="expires"]').val(); - $('[name="expires"]').closest('.row')[state == YES ? 'show' : 'hide'](); + $('[name="expires"]').closest('div')[state == YES ? 'show' : 'hide'](); var show_custom = ( state == YES && expires == CUSTOM_DATE ); - $('[name="expires_custom"]').closest('.row')[show_custom ? 'show' : 'hide'](); - $('[name$="_reason"]').closest('.row')[( state == NO || state == NO_INCOMPLETE ) ? 'show' : 'hide'](); + $('[name="expires_custom"]').closest('div')[show_custom ? 'show' : 'hide'](); + $('[name$="_reason"]').closest('div')[( state == NO || state == NO_INCOMPLETE ) ? 'show' : 'hide'](); $('[for="id_user_reason"]')[0].innerHTML = [( state == NO ) ? 'Reason for rejection (user)' : 'Reason for incomplete (user)']; $('[name="user_reason"]')[0].placeholder = [( state == NO ) ? 'Reason for rejection (user)' : 'Reason for incomplete (user)']; $('[for="id_internal_reason"]')[0].innerHTML = [( state == NO ) ? 'Reason for rejection (internal)' : 'Reason for incomplete (internal)']; $('[name="internal_reason"]')[0].placeholder = [( state == NO ) ? 'Reason for rejection (internal)' : 'Reason for incomplete (internal)']; + var internal_comment = $('[name="internal_comment"]'); + if (internal_comment.length) { + internal_comment.closest('div')[!state ? 'show' : 'hide'](); + } } toggle_fields(); $('[name="state"], [name="expires"]').on('change', toggle_fields); diff --git a/jasmin_services/templates/admin/jasmin_services/request/decide.html b/jasmin_services/templates/admin/jasmin_services/request/decide.html deleted file mode 100644 index 14d3a4d..0000000 --- a/jasmin_services/templates/admin/jasmin_services/request/decide.html +++ /dev/null @@ -1,277 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load static admin_urls pretty_name markdown_deux_tags %} - -{% block extrahead %}{{ block.super }} - - {{ media }} -{% endblock %} -{% block extrastyle %}{{ block.super }} - - -{% endblock %} -{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %} - -{% if not is_popup %} - {% block breadcrumbs %} - - {% endblock %} -{% endif %} - -{% block content %} - {% if not is_popup %} - - {% endif %} - - - -
-
- {% csrf_token %} -
- {% if is_popup %}{% endif %} - {% if form.errors %} -

Please correct the errors below.

- {% endif %} - -
-
- -

{{ original.access.role }}

-
-
- -

{{ original.access.user.username }}

-
-
- -

{{ original.access.user.email }}

-
-
- -

{{ original.access.user.get_full_name }}

-
-
- -

{{ original.access.user.institution }}

-
-
- -

{{ original.access.user.discipline }}

-
-
- -

{{ original.access.user.degree|default:"N/A" }}

-
- - {% for datum in original.metadata.all %} -
- -

{{ datum.value }}

-
- {% endfor %} - - {% if rejected or previous_grant %} -
-

Previous access

- - {% if rejected %} -
- {% for req in rejected %} - - {% endfor %} -
- {% endif %} - {% if previous_grant.revoked %} - - {% elif previous_grant.expired %} - - {% elif previous_grant.expiring %} - - {% else %} - - {% endif %} -
- {% endif %} - - {% if grants %} -
-

Other active grants

-
- {% for grant in grants %} - {% if grant.revoked %} - - {% elif grant.expired %} - - {% elif grant.expiring %} - - {% else %} - - {% endif %} - {% endfor %} -
-
- {% endif %} - -
-

Make decision

- - {% for field in form %} -
- {{ field.errors }} -
- {{ field.label_tag }} {{ field }} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} -
-
- {% endfor %} -
- -
- -
-
-
-
- - -{% endblock %} diff --git a/jasmin_services/templates/jasmin_notifications/mail/pending_summary/content.txt b/jasmin_services/templates/jasmin_notifications/mail/pending_summary/content.txt index da90d7d..7c04801 100644 --- a/jasmin_services/templates/jasmin_notifications/mail/pending_summary/content.txt +++ b/jasmin_services/templates/jasmin_notifications/mail/pending_summary/content.txt @@ -19,7 +19,7 @@ Outstanding requests for the USER role for services with no approver or with the Outstanding requests for the MANAGER role for all services: {% if manager_requests|length != 0 %}{% for mr_cat, mrs in manager_requests.items %} {{ mr_cat }} - Username, Serivce, Role, Link + Username, Service, Role, Link {% for mr in mrs %} {{ mr.access.user.username }}, {{ mr.access.role.service.name }}, {{ mr.access.role.name }}, {{ url }}/services/request/{{ mr.id }}/decide/ {% endfor %}{% endfor %} @@ -33,4 +33,4 @@ Outstanding JASMIN account applications: {% for ap in applications %} {{ ap.email }}, {{ url }}/admin/jasmin_registration/application/{{ ap.id }}/decide/ {% endfor %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/jasmin_services/templates/jasmin_services/includes/display_accesses.html b/jasmin_services/templates/jasmin_services/includes/display_accesses.html index a4c74a6..00b8868 100644 --- a/jasmin_services/templates/jasmin_services/includes/display_accesses.html +++ b/jasmin_services/templates/jasmin_services/includes/display_accesses.html @@ -3,6 +3,9 @@ + {% if for_managers %} + Type + {% endif %} Role Status Created @@ -29,6 +32,9 @@ {% endif %} + {% if for_managers %} + {{ access.frontend.type }} + {% endif %} {{ access.access.role.name }} {{ access.status }} {{ access.frontend.start|date:"DATE_FORMAT" }} @@ -36,10 +42,10 @@ {% if access.status == "EXPIRING" or access.status == "EXPIRED" %} {% if access.frontend.may_apply %} - - Extend - - {% endif %} + + Extend + + {% endif %} {% elif access.status == "REVOKED" or access.status == "REJECTED" or access.status == "INCOMPLETE" %} - -
-
-
Reason for rejection (user)
-

{{ req.user_reason|markdown }}

- {% if req.internal_reason %} -
Reason for rejection (internal)
-

{{ req.internal_reason|markdown }}

- {% endif %} -

Requested at {{ req.requested_at }}

-
-
- - {% endfor %} - - - - {% endif %} - {% if grant.revoked %} -
-
- -
-
- {% elif grant.expired %} -
-
- -
-
- {% elif grant.expiring %} -
-
- -
-
- {% endif %} - - {% block extra_context %}{% endblock %} - - {% for datum in pending.metadata.all %} -
- -
-

{{ datum.value }}

-
-
- {% endfor %} - - {% bootstrap_form form layout='horizontal' %} - -
-
- -
-
- - -
-
-
Other approvers
-
- {% if approvers %} -
    - {% for user in approvers %} -
  • {{ user.username }}
  • - {% endfor %} -
- {% else %} - You are the only approver for this service. - {% endif %} -
-
-
- - {% endblock %} + + + +
+
+
+

Application Metadata

+
+
+ {% display_metadata object.metadata help_text='The user supplied the following metadata with their application:' %} +
+
+
+
+
+
+

Decide

+
+
+
+ {% bootstrap_form_errors form %} + {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button "Save" button_type="submit" button_class="btn-primary" %} +
+
+
+
+ {% if accesses %} +
+
+
+

Request and Grant History

+
+ + {% display_accesses accesses for_managers=True user=user %} +
+
+
+ {% endif %} +{% endblock %} - {% block stylesheets_page %} - - {% endblock %} +{% block stylesheets_page %} + +{% endblock %} - {% block js_page %} - - - {% endblock %} +{% block js_page %} + + +{% endblock %} diff --git a/jasmin_services/templatetags/service_tags.py b/jasmin_services/templatetags/service_tags.py index 2aeb374..f70e4f3 100644 --- a/jasmin_services/templatetags/service_tags.py +++ b/jasmin_services/templatetags/service_tags.py @@ -38,12 +38,15 @@ def pending_req_count(context, service): @register.inclusion_tag("jasmin_services/includes/display_accesses.html") -def display_accesses(accesses): +def display_accesses(accesses, for_managers=False, user=False): """Template tag to display a list of accesses (requests and or grants).""" - return {"accesses": accesses} + return {"accesses": accesses, "user": user, "for_managers": for_managers} @register.inclusion_tag("jasmin_services/includes/display_metadata.html") -def display_metadata(metadata): +def display_metadata( + metadata, + help_text="When you submitted your application, you supplied the following information:", +): """Template tag to display a list of metadata for an access.""" - return {"metadata": metadata} + return {"metadata": metadata, "help_text": help_text} diff --git a/jasmin_services/urls.py b/jasmin_services/urls.py index 93ae552..10c35d2 100644 --- a/jasmin_services/urls.py +++ b/jasmin_services/urls.py @@ -72,6 +72,6 @@ def swapable_view(setting_name: str, default_view_class: django.views.View) -> d RoleApplyView.as_view(), name="role_apply", ), - path("request//decide/", views.request_decide, name="request_decide"), + path("request//decide/", views.RequestDecideView.as_view(), name="request_decide"), path("grant//review/", views.grant_review, name="grant_review"), ] diff --git a/jasmin_services/views/__init__.py b/jasmin_services/views/__init__.py index 6e5ca92..f69bc84 100644 --- a/jasmin_services/views/__init__.py +++ b/jasmin_services/views/__init__.py @@ -1,7 +1,7 @@ from .grant_review import grant_review from .grant_role import grant_role from .my_services import my_services -from .request_decide import request_decide +from .request_decide import RequestDecideView from .reverse_dns_check import reverse_dns_check from .role_apply import RoleApplyView from .service_details import ServiceDetailsView @@ -14,7 +14,7 @@ "grant_review", "grant_role", "my_services", - "request_decide", + "RequestDecideView", "reverse_dns_check", "service_details", "service_list", diff --git a/jasmin_services/views/mixins.py b/jasmin_services/views/mixins.py index eaeb2e8..00b09ed 100644 --- a/jasmin_services/views/mixins.py +++ b/jasmin_services/views/mixins.py @@ -1,8 +1,15 @@ +import random +import string +from datetime import date + import asgiref.sync import django.contrib.auth.mixins import django.http +import django.urls +from django.db.models import Q from .. import models +from . import common, mixins class WithServiceMixin: @@ -79,3 +86,62 @@ async def func(): return func() return response + + +class AccessListMixin: + """Mixin to process a list of grants and requests for display on the frontend.""" + + @staticmethod + async def process_access(access, user, id_part, id_, may_apply_override=None): + # Allow overriding the user_may_apply calculation. + if may_apply_override is None: + may_apply = await access.access.role.auser_may_apply(user) + else: + may_apply = may_apply_override + + access.frontend = { + "start": ( + access.requested_at if isinstance(access, models.Request) else access.granted_at + ), + "id": f"{id_part}_{id_}", + "type": ("REQUEST" if isinstance(access, models.Request) else "GRANT"), + "apply_url": django.urls.reverse( + "jasmin_services:role_apply", + kwargs={ + "category": access.access.role.service.category.name, + "service": access.access.role.service.name, + "role": access.access.role.name, + "bool_grant": 0 if isinstance(access, models.Request) else 1, + "previous": access.id, + }, + ), + "may_apply": may_apply, + } + return access + + async def display_accesses(self, user, grants, requests, may_apply_override=None): + """Process a list of either requests or grants for display.""" + processed = [] + + # This ID is used to create CSS ids. Must be unique per access. + id_part = "".join(random.choice(string.ascii_lowercase) for i in range(5)) + id_ = 0 + # We loop through the list, and add some information which is not otherwise available. + async for grant in grants: + processed.append( + await self.process_access( + grant, user, id_part, id_, may_apply_override=may_apply_override + ) + ) + id_ += 1 + async for request in requests: + processed.append( + await self.process_access( + request, user, id_part, id_, may_apply_override=may_apply_override + ) + ) + id_ += 1 + + accesses = sorted(processed, key=lambda x: x.frontend["start"], reverse=True) + + return accesses diff --git a/jasmin_services/views/request_decide.py b/jasmin_services/views/request_decide.py index 8fc74e3..3304e66 100644 --- a/jasmin_services/views/request_decide.py +++ b/jasmin_services/views/request_decide.py @@ -1,92 +1,111 @@ -import logging - +import asgiref.sync +import django.contrib.auth.mixins import django.db -from django import http -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.shortcuts import redirect, render -from django.views.decorators.http import require_http_methods +import django.urls +import django.views.generic.edit -from ..forms import DecisionForm -from ..models import Request, RequestState -from .common import redirect_to_service +from .. import forms, models +from . import mixins -_log = logging.getLogger(__name__) +class RequestDecideView( + django.contrib.auth.mixins.LoginRequiredMixin, + django.contrib.auth.mixins.UserPassesTestMixin, + mixins.WithServiceMixin, + mixins.AccessListMixin, + django.views.generic.UpdateView, +): + model = models.Request + form_class = forms.DecisionForm -@require_http_methods(["GET", "POST"]) -@login_required -@django.db.transaction.atomic -def request_decide(request, pk): - """ - Handler for ``/request//decide/``. + PERMISSION = "jasmin_services.decide_request" - Responds to GET and POST. The user must have the ``decide_request`` - permission for the role that the request is for. The request must be active - and pending. + def setup(self, request, *args, **kwargs): + """Set up extra class attributes depending on the service and role we are dealing with. - Presents information about the request along with a form to collect a decision. - """ - # Try to find the specified request - try: - pending = Request.objects.get(pk=pk) - except Request.DoesNotExist: - raise http.Http404("Request does not exist") - # The current user must have permission to grant the role - permission = "jasmin_services.decide_request" - if ( - not request.user.has_perm(permission) - and not request.user.has_perm(permission, pending.access.role.service) - and not request.user.has_perm(permission, pending.access.role) - ): - messages.error(request, "Request does not exist") - return redirect_to_service(pending.access.role.service, "service_details") - # If the request is not pending, redirect to the list of pending requests - if not pending.active or pending.state != RequestState.PENDING: - messages.info(request, "This request has already been resolved") - return redirect( - "jasmin_services:service_requests", - category=pending.access.role.service.category.name, - service=pending.access.role.service.name, + These are needed throughout and not just in the context. + """ + # pylint: disable=attribute-defined-outside-init + super().setup(request, *args, **kwargs) + self.object = self.get_object() + self.service = self.get_service( + self.object.access.role.service.category.name, self.object.access.role.service.name ) - # If the user requesting access has an active grant, find it - grant = pending.previous_grant - # Find all the rejected requests for the role/user since the active grant - rejected = Request.objects.filter( - access=pending.access, - state=RequestState.REJECTED, - previous_grant=pending.previous_grant, - ).order_by("requested_at") - # Process the form if this is a POST request, otherwise just set it up - if request.method == "POST": - form = DecisionForm(pending, request.user, data=request.POST) - if form.is_valid(): - with transaction.atomic(): - form.save() - return redirect_to_service(pending.access.role.service, "service_requests") - else: - messages.error(request, "Error with one or more fields") - else: - form = DecisionForm(pending, request.user) - templates = [ - "jasmin_services/{}/{}/request_decide.html".format( - pending.access.role.service.category.name, pending.access.role.service.name - ), - "jasmin_services/{}/request_decide.html".format(pending.access.role.service.category.name), - "jasmin_services/request_decide.html", - ] - return render( - request, - templates, - { - "service": pending.access.role.service, - "pending": pending, - "rejected": rejected, - "grant": grant, + + def test_func(self): + """Define the test for the UserPassesTestMixin. + + Return True if access should be allowed, false otherwise. + """ + return self.request.user.has_perm( + self.PERMISSION, self.service + ) or self.request.user.has_perm(self.PERMISSION, self.object.access.role) + + def get_template_names(self): + """Define template to be used by the TemplateResponseMixin.""" + return [ + f"jasmin_services/{self.service.category.name}/{self.service.name}/request_decide.html", + f"jasmin_services/{self.service.category.name}/request_decide.html", + "jasmin_services/request_decide.html", + ] + + def get_form_kwargs(self): + """Get kwargs for building the form for FormMixin.""" + kwargs = super().get_form_kwargs() + kwargs["request"] = kwargs.pop( + "instance" + ) # Since the form is not a ModelForm, it expects the instance to be named "request" + # If the user is CEDA staff, inject the internal comment back to the form. + if self.request.user.is_staff: + kwargs["initial"]["internal_comment"] = kwargs["request"].internal_comment + return kwargs | {"approver": self.request.user} + + def form_valid(self, form): + """Make the transtaction atomic. Form does lots of complicated stuff.""" + with django.db.transaction.atomic(): + return super().form_valid(form) + + def get_success_url(self): + """Define the url to redirect to on success.""" + # If the is a next url, and it resolves to a place on the site, go there. + next = self.request.GET.get("next", None) + if next is not None: + try: + django.urls.resolve(next) + except django.urls.Resolver404: + pass + else: + return next + # Else redirect to the service. + return f"/services/{self.service.category.name}/{self.service.name}/" + + def get_context_data(self, **kwargs): + """Add to the template context.""" + context = super().get_context_data(**kwargs) + + grants = models.Grant.objects.filter( + access__role__service=self.service, access__user=self.object.access.user + ).prefetch_related("metadata", "access__role__service__category") + requests = ( + models.Request.objects.filter( + access__role__service=self.service, + access__user=self.object.access.user, + resulting_grant__isnull=True, + ) + .exclude(pk=self.object.pk) + .prefetch_related("metadata", "access__role__service__category") + ) + + context |= { + "accesses": asgiref.sync.async_to_sync(self.display_accesses)( + self.request.user, grants, requests, may_apply_override=False + ), + "service": self.service, + # "pending": self.object, + # "rejected": rejected, + # "grant": self.object.previous_grant, # The previous grant. # The list of approvers to show here is any user who has the correct # permission for either the role or the service - "approvers": pending.access.role.approvers.exclude(pk=request.user.pk), - "form": form, - }, - ) + "approvers": self.object.access.role.approvers.exclude(pk=self.request.user.pk), + } + return context diff --git a/jasmin_services/views/service_details.py b/jasmin_services/views/service_details.py index e6eb81d..9ffee6a 100644 --- a/jasmin_services/views/service_details.py +++ b/jasmin_services/views/service_details.py @@ -12,6 +12,7 @@ class ServiceDetailsView( mixins.AsyncLoginRequiredMixin, mixins.WithServiceMixin, + mixins.AccessListMixin, mixins.AsyncTemplateView, ): """Handle ``///``. @@ -39,46 +40,6 @@ async def get_service_roleholders(service, role_name): result.append(item.access.user) return result - @staticmethod - async def process_access(access, user, id_part, id_): - access.frontend = { - "start": ( - access.requested_at if isinstance(access, models.Request) else access.granted_at - ), - "id": f"{id_part}_{id_}", - "type": ("REQUEST" if isinstance(access, models.Request) else "GRANT"), - "apply_url": django.urls.reverse( - "jasmin_services:role_apply", - kwargs={ - "category": access.access.role.service.category.name, - "service": access.access.role.service.name, - "role": access.access.role.name, - "bool_grant": 0 if isinstance(access, models.Request) else 1, - "previous": access.id, - }, - ), - "may_apply": await access.access.role.auser_may_apply(user), - } - return access - - async def display_accesses(self, user, grants, requests): - """Process a list of either requests or grants for display.""" - processed = [] - - # This ID is used to create CSS ids. Must be unique per access. - id_part = "".join(random.choice(string.ascii_lowercase) for i in range(5)) - id_ = 0 - # We loop through the list, and add some information which is not otherwise available. - async for grant in grants: - processed.append(await self.process_access(grant, user, id_part, id_)) - id_ += 1 - async for request in requests: - processed.append(await self.process_access(request, user, id_part, id_)) - id_ += 1 - - accesses = sorted(processed, key=lambda x: x.frontend["start"], reverse=True) - return accesses - async def get_context_data(self, **kwargs): """Add information about service to the context.""" self.service = await self.aget_service(kwargs["category"], kwargs["service"])