diff --git a/coldfront/config/core.py b/coldfront/config/core.py index 2823494fb..3c68dfaa2 100644 --- a/coldfront/config/core.py +++ b/coldfront/config/core.py @@ -21,6 +21,8 @@ #------------------------------------------------------------------------------ # Allocation related #------------------------------------------------------------------------------ +ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = ENV.bool('ALLOCATION_ENABLE_CHANGE_REQUESTS', default=True) +ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = ENV.list('ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS', cast=int, default=[30, 60, 90]) ALLOCATION_ENABLE_ALLOCATION_RENEWAL = ENV.bool('ALLOCATION_ENABLE_ALLOCATION_RENEWAL', default=True) ALLOCATION_FUNCS_ON_EXPIRE = ['coldfront.core.allocation.utils.test_allocation_function', ] diff --git a/coldfront/core/allocation/admin.py b/coldfront/core/allocation/admin.py index c5b918d9a..729c2faad 100644 --- a/coldfront/core/allocation/admin.py +++ b/coldfront/core/allocation/admin.py @@ -9,7 +9,10 @@ AllocationAttribute, AllocationAttributeType, AllocationAttributeUsage, + AllocationChangeRequest, + AllocationAttributeChangeRequest, AllocationStatusChoice, + AllocationChangeStatusChoice, AllocationUser, AllocationUserNote, AllocationUserStatusChoice, @@ -53,7 +56,7 @@ class AllocationAdmin(SimpleHistoryAdmin): readonly_fields_change = ( 'project', 'justification', 'created', 'modified',) fields_change = ('project', 'resources', 'quantity', 'justification', - 'status', 'start_date', 'end_date', 'description', 'created', 'modified', 'is_locked') + 'status', 'start_date', 'end_date', 'description', 'created', 'modified', 'is_locked', 'is_changeable') list_display = ('pk', 'project_title', 'project_pi', 'resource', 'quantity', 'justification', 'start_date', 'end_date', 'status', 'created', 'modified', ) inlines = [AllocationUserInline, @@ -343,3 +346,19 @@ def project_pi(self, obj): @admin.register(AllocationAccount) class AllocationAccountAdmin(SimpleHistoryAdmin): list_display = ('name', 'user', ) + + +@admin.register(AllocationChangeStatusChoice) +class AllocationChangeStatusChoiceAdmin(admin.ModelAdmin): + list_display = ('name', ) + + +@admin.register(AllocationChangeRequest) +class AllocationChangeRequestAdmin(admin.ModelAdmin): + list_display = ('pk', 'allocation', 'status', 'end_date_extension', 'justification', 'notes', ) + + +@admin.register(AllocationAttributeChangeRequest) +class AllocationChangeStatusChoiceAdmin(admin.ModelAdmin): + list_display = ('pk', 'allocation_change_request', 'allocation_attribute', 'new_value', ) + diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index d0e9ddd0b..1b4967291 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -3,6 +3,7 @@ from coldfront.core.allocation.models import (Allocation, AllocationAccount, AllocationAttributeType, + AllocationAttribute, AllocationStatusChoice) from coldfront.core.allocation.utils import get_user_resources from coldfront.core.project.models import Project @@ -11,6 +12,8 @@ ALLOCATION_ACCOUNT_ENABLED = import_from_settings( 'ALLOCATION_ACCOUNT_ENABLED', False) +ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings( + 'ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS', []) class AllocationForm(forms.Form): @@ -64,6 +67,7 @@ class AllocationUpdateForm(forms.Form): description = forms.CharField(max_length=512, label='Description', required=False) + is_changeable = forms.BooleanField(required=False) def clean(self): cleaned_data = super().clean() @@ -174,3 +178,75 @@ class AllocationAccountForm(forms.ModelForm): class Meta: model = AllocationAccount fields = ['name', ] + + +class AllocationAttributeChangeForm(forms.Form): + pk = forms.IntegerField(required=False, disabled=True) + name = forms.CharField(max_length=150, required=False, disabled=True) + value = forms.CharField(max_length=150, required=False, disabled=True) + new_value = forms.CharField(max_length=150, required=False, disabled=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['pk'].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + + if cleaned_data.get('new_value') != "": + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('pk')) + allocation_attribute.value = cleaned_data.get('new_value') + allocation_attribute.clean() + + +class AllocationAttributeUpdateForm(forms.Form): + change_pk = forms.IntegerField(required=False, disabled=True) + attribute_pk = forms.IntegerField(required=False, disabled=True) + name = forms.CharField(max_length=150, required=False, disabled=True) + value = forms.CharField(max_length=150, required=False, disabled=True) + new_value = forms.CharField(max_length=150, required=False, disabled=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['change_pk'].widget = forms.HiddenInput() + self.fields['attribute_pk'].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('attribute_pk')) + + allocation_attribute.value = cleaned_data.get('new_value') + allocation_attribute.clean() + + +class AllocationChangeForm(forms.Form): + EXTENSION_CHOICES = [ + (0, "No Extension") + ] + for choice in ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS: + EXTENSION_CHOICES.append((choice, "{} days".format(choice))) + + end_date_extension = forms.TypedChoiceField( + label='Request End Date Extension', + choices = EXTENSION_CHOICES, + coerce=int, + required=False, + empty_value=0,) + justification = forms.CharField( + label='Justification for Changes', + widget=forms.Textarea, + required=False, + help_text='Justification for requesting this allocation change request.') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class AllocationChangeNoteForm(forms.Form): + notes = forms.CharField( + max_length=512, + label='Notes', + required=False, + widget=forms.Textarea, + help_text="Leave any feedback about the allocation change request.") + diff --git a/coldfront/core/allocation/management/commands/add_allocation_defaults.py b/coldfront/core/allocation/management/commands/add_allocation_defaults.py index c8c1ad437..0a9893abf 100644 --- a/coldfront/core/allocation/management/commands/add_allocation_defaults.py +++ b/coldfront/core/allocation/management/commands/add_allocation_defaults.py @@ -3,6 +3,7 @@ from coldfront.core.allocation.models import (AttributeType, AllocationAttributeType, AllocationStatusChoice, + AllocationChangeStatusChoice, AllocationUserStatusChoice) @@ -20,6 +21,9 @@ def handle(self, *args, **options): 'Renewal Requested', 'Revoked', 'Unpaid',): AllocationStatusChoice.objects.get_or_create(name=choice) + for choice in ('Pending', 'Approved', 'Denied',): + AllocationChangeStatusChoice.objects.get_or_create(name=choice) + for choice in ('Active', 'Error', 'Removed', ): AllocationUserStatusChoice.objects.get_or_create(name=choice) diff --git a/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py b/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py new file mode 100644 index 000000000..8a5b95d0a --- /dev/null +++ b/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py @@ -0,0 +1,135 @@ +# Generated by Django 2.2.18 on 2021-11-02 14:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('allocation', '0003_auto_20191018_1049'), + ] + + operations = [ + migrations.CreateModel( + name='AllocationChangeRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('end_date_extension', models.IntegerField(blank=True, null=True)), + ('justification', models.TextField()), + ('notes', models.CharField(blank=True, max_length=512, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AllocationChangeStatusChoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(max_length=64)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='allocation', + name='is_changeable', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='allocationattributetype', + name='is_changeable', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalallocation', + name='is_changeable', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalallocationattributetype', + name='is_changeable', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='HistoricalAllocationChangeRequest', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('end_date_extension', models.IntegerField(blank=True, null=True)), + ('justification', models.TextField()), + ('notes', models.CharField(blank=True, max_length=512, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('allocation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.Allocation')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationChangeStatusChoice', verbose_name='Status')), + ], + options={ + 'verbose_name': 'historical allocation change request', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalAllocationAttributeChangeRequest', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('new_value', models.CharField(max_length=128)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('allocation_attribute', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationAttribute')), + ('allocation_change_request', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationChangeRequest')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical allocation attribute change request', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddField( + model_name='allocationchangerequest', + name='allocation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation'), + ), + migrations.AddField( + model_name='allocationchangerequest', + name='status', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationChangeStatusChoice', verbose_name='Status'), + ), + migrations.CreateModel( + name='AllocationAttributeChangeRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('new_value', models.CharField(max_length=128)), + ('allocation_attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationAttribute')), + ('allocation_change_request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationChangeRequest')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py b/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py new file mode 100644 index 000000000..7512c62f2 --- /dev/null +++ b/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.24 on 2021-11-17 19:13 + +from django.db import migrations + + +def create_status_choices(apps, schema_editor): + AllocationChangeStatusChoice = apps.get_model('allocation', "AllocationChangeStatusChoice") + for choice in ('Pending', 'Approved', 'Denied',): + AllocationChangeStatusChoice.objects.get_or_create(name=choice) + + +class Migration(migrations.Migration): + + dependencies = [ + ('allocation', '0004_auto_20211102_1017'), + ] + + operations = [ + migrations.RunPython(create_status_choices), + ] diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 6d99ef65b..d70893130 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -46,6 +46,7 @@ class Allocation(TimeStampedModel): justification = models.TextField() description = models.CharField(max_length=512, blank=True, null=True) is_locked = models.BooleanField(default=False) + is_changeable = models.BooleanField(default=False) history = HistoricalRecords() class Meta: @@ -206,6 +207,7 @@ class AllocationAttributeType(TimeStampedModel): is_required = models.BooleanField(default=False) is_unique = models.BooleanField(default=False) is_private = models.BooleanField(default=True) + is_changeable = models.BooleanField(default=False) history = HistoricalRecords() def __str__(self): @@ -238,19 +240,19 @@ def clean(self): if expected_value_type == "Int" and not isinstance(literal_eval(self.value), int): raise ValidationError( - 'Invalid Value "%s". Value must be an integer.' % (self.value)) + 'Invalid Value "%s" for "%s". Value must be an integer.' % (self.value, self.allocation_attribute_type.name)) elif expected_value_type == "Float" and not (isinstance(literal_eval(self.value), float) or isinstance(literal_eval(self.value), int)): raise ValidationError( - 'Invalid Value "%s". Value must be a float.' % (self.value)) + 'Invalid Value "%s" for "%s". Value must be a float.' % (self.value, self.allocation_attribute_type.name)) elif expected_value_type == "Yes/No" and self.value not in ["Yes", "No"]: raise ValidationError( - 'Invalid Value "%s". Allowed inputs are "Yes" or "No".' % (self.value)) + 'Invalid Value "%s" for "%s". Allowed inputs are "Yes" or "No".' % (self.value, self.allocation_attribute_type.name)) elif expected_value_type == "Date": try: datetime.datetime.strptime(self.value.strip(), "%Y-%m-%d") except ValueError: raise ValidationError( - 'Invalid Value "%s". Date must be in format YYYY-MM-DD' % (self.value)) + 'Invalid Value "%s" for "%s". Date must be in format YYYY-MM-DD' % (self.value, self.allocation_attribute_type.name)) def __str__(self): return '%s' % (self.allocation_attribute_type.name) @@ -302,3 +304,44 @@ def __str__(self): class Meta: ordering = ['name', ] + + +class AllocationChangeStatusChoice(TimeStampedModel): + name = models.CharField(max_length=64) + + def __str__(self): + return self.name + + class Meta: + ordering = ['name', ] + + +class AllocationChangeRequest(TimeStampedModel): + allocation = models.ForeignKey(Allocation, on_delete=models.CASCADE,) + status = models.ForeignKey( + AllocationChangeStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + end_date_extension = models.IntegerField(blank=True, null=True) + justification = models.TextField() + notes = models.CharField(max_length=512, blank=True, null=True) + history = HistoricalRecords() + + @property + def get_parent_resource(self): + if self.allocation.resources.count() == 1: + return self.allocation.resources.first() + else: + return self.allocation.resources.filter(is_allocatable=True).first() + + def __str__(self): + return "%s (%s)" % (self.get_parent_resource.name, self.allocation.project.pi) + + +class AllocationAttributeChangeRequest(TimeStampedModel): + allocation_change_request = models.ForeignKey(AllocationChangeRequest, on_delete=models.CASCADE) + allocation_attribute = models.ForeignKey(AllocationAttribute, on_delete=models.CASCADE) + new_value = models.CharField(max_length=128) + history = HistoricalRecords() + + def __str__(self): + return '%s' % (self.allocation_attribute.allocation_attribute_type.name) + diff --git a/coldfront/core/allocation/templates/allocation/allocation_change.html b/coldfront/core/allocation/templates/allocation/allocation_change.html new file mode 100644 index 000000000..a7128649f --- /dev/null +++ b/coldfront/core/allocation/templates/allocation/allocation_change.html @@ -0,0 +1,137 @@ +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} +Request Allocation Change +{% endblock %} + + +{% block content %} + +

Request change to {{ allocation.get_parent_resource }} for project: {{ allocation.project.title }}

+
+ +

+ Request changes to an existing allocation using the form below. For each change + you must provide a justification. +

+ +
+
+
+

Allocation Information

+
+ +
+ {% csrf_token %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if allocation.is_changeable %} + + + + + {% endif %} + + + + + + + + + + + + + + {% if allocation.is_locked %} + + + {% else %} + + + {% endif %} + +
Project:{{ allocation.project }}
Resource{{ allocation.resources.all|pluralize }} in allocation:{{ allocation.get_resources_as_string }}
Justification:{{ allocation.justification }}
Status:{{ allocation.status }}
Start Date:{{ allocation.start_date }}
End Date: + {{ allocation.end_date }} + {% if allocation.is_locked and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} + + Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}} - Not renewable + + {% endif %} +
Request End Date Extension: + {{ form.end_date_extension }} +
Description:{{ allocation.description }}
Created:{{ allocation.created|date:"M. d, Y" }}
Last Modified:{{ allocation.modified|date:"M. d, Y" }}
LockedUnlocked
+
+
+
+ + {% if formset %} +
+
+

Allocation Attributes

+
+
+
+ + + + + + + + + + {% for form in formset %} + + + + + + {% endfor %} + +
AttributeCurrent ValueRequest New Value
{{form.name.value}}{{form.value.value}}{{form.new_value}}
+
+ {{ formset.management_form }} +
+
+ {% endif %} + +
+ {{form.justification | as_crispy_field }} + + Back to + Allocation
+
+
+ + +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_change_detail.html b/coldfront/core/allocation/templates/allocation/allocation_change_detail.html new file mode 100644 index 000000000..7886d2de5 --- /dev/null +++ b/coldfront/core/allocation/templates/allocation/allocation_change_detail.html @@ -0,0 +1,219 @@ +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} +Allocation Change Detail +{% endblock %} + + +{% block content %} + +

Change requested to {{ allocation_change.allocation.get_parent_resource }} for project: {{ allocation_change.allocation.project.title }}

+
+ + {% if allocation_change.status.name == "Approved" %} + + {% elif allocation_change.status.name == "Denied"%} + + {% else %} + + {% endif %} + +
+
+
+

Allocation Information

+
+ +
+ {% csrf_token %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if allocation_change.allocation.is_changeable %} + + + + + {% endif %} + + + + + + + + + + + + + + {% if allocation_change.allocation.is_locked %} + + + {% else %} + + + {% endif %} + +
Project:{{ allocation_change.allocation.project }}
Resource{{ allocation_change.allocation.resources.all|pluralize }} in allocation:{{ allocation_change.allocation.get_resources_as_string }}
Justification:{{ allocation_change.allocation.justification }}
Status:{{ allocation_change.allocation.status }}
Start Date:{{ allocation_change.allocation.start_date }}
End Date: + {{ allocation_change.allocation.end_date }} + {% if allocation_change.allocation.is_locked and allocation_change.allocation.status.name == 'Approved' and allocation_change.allocation.expires_in <= 60 and allocation_change.allocation.expires_in >= 0 %} + + Expires in {{allocation_change.allocation.expires_in}} day{{allocation_change.allocation.expires_in|pluralize}} - Not renewable + + {% endif %} +
Requested End Date Extension: + {{allocation_change_form.end_date_extension}} +
Description:{{ allocation_change.allocation.description }}
Created:{{ allocation_change.allocation.created|date:"M. d, Y" }}
Last Modified:{{ allocation_change.allocation.modified|date:"M. d, Y" }}
LockedUnlocked
+
+
+
+ + +
+
+

Allocation Attributes

+
+
+ {% if attribute_changes %} +
+ + + + + {% if allocation_change.status.name == 'Pending' %} + + {% endif %} + + + + + {% for form in formset %} + + + {% if allocation_change.status.name == 'Pending' %} + + {% if request.user.is_superuser %} + + {% else %} + + {% endif %} + {% else %} + {% if form.new_value.value == '' %} + + {% else %} + + {% endif %} + {% endif %} + + {% endfor %} + +
AttributeCurrent ValueRequested New Value
{{form.name.value}}{{form.value.value}} + {{form.new_value}} + + + + {{form.new_value.value}}None{{form.new_value.value}}
+
+ {% else %} + + {% endif %} + {{ formset.management_form }} +
+
+ +

{{allocation_change_form.justification | as_crispy_field}}

+ +
+ + {% if request.user.is_superuser %} +
+
+

Actions

+
+
+ + + {% csrf_token %} + {{note_form.notes | as_crispy_field}} +
+ {% if allocation_change.status.name == 'Pending' %} + + + {% endif %} + +
+
+
+ + {% endif %} +
+ + + + View Allocation + + {% if request.user.is_superuser %} + + See all allocation change requests + + {% endif %} +
+ + +{% endblock %} + diff --git a/coldfront/core/allocation/templates/allocation/allocation_change_list.html b/coldfront/core/allocation/templates/allocation/allocation_change_list.html new file mode 100644 index 000000000..7492595ac --- /dev/null +++ b/coldfront/core/allocation/templates/allocation/allocation_change_list.html @@ -0,0 +1,74 @@ +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load common_tags %} +{% load static %} + + +{% block title %} +Allocation Review New and Pending Change Requests +{% endblock %} + + +{% block content %} +

Allocation Change Requests

+ +{% if allocation_change_list %} +
+ + + + + + + + + + + + + + {% for change in allocation_change_list %} + + + + + + + + + + {% endfor %} + +
#RequestedProject TitlePIResourceExtensionActions
{{change.pk}}{{ change.modified|date:"M. d, Y" }}{{change.allocation.project.title|truncatechars:50}}{{change.allocation.project.pi.first_name}} {{change.allocation.project.pi.last_name}} + ({{change.allocation.project.pi.username}}){{change.allocation.get_parent_resource}} + {% if change.end_date_extension == 0 %} + {% else %} {{change.end_date_extension}} days + {% endif %} + + {% if change.allocationattributechangerequest_set.all %} + + Approve + + Details + {% else %} + Approve + Details + {% endif %} +
+
+{% else %} +
+ No new or pending allocation change requests! +
+{% endif %} + + +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index c779570f1..91a507c2a 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -28,8 +28,22 @@

Allocation Detail

-

Allocation Information

+ {% if allocation.is_changeable and is_allowed_to_update_project %} +
+
+

Allocation Information

+
+ +
+ {% else %} +

Allocation Information

+ {% endif %}
+
{% csrf_token %} @@ -121,6 +135,14 @@

Allocation Information

{% endif %} + {% if request.user.is_superuser or request.user.is_staff %} + + Allow Change Requests + + {{ form.is_changeable }} + + + {% endif %}
{% if request.user.is_superuser %} @@ -165,7 +187,9 @@

Alloc {% else %} {{attribute}} - {{attribute.value}} + + {{attribute.value}} + {% endif %} {% endfor %} @@ -189,6 +213,56 @@

{{attribute}}

{% endif %} + +
+
+

Allocation Change Requests

{{allocation_changes.count}} +
+ +
+ {% if allocation_changes %} +
+ + + + + + + + + + + {% for change_request in allocation_changes %} + + + {% if change_request.status.name == 'Approved' %} + + {% elif change_request.status.name == 'Denied' %} + + {% else %} + + {% endif %} + + {% if change_request.notes %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
Date RequestedStatusNotesActions
{{ change_request.modified|date:"M. d, Y" }}{{ change_request.status.name }}{{ change_request.status.name }}{{ change_request.status.name }}{{change_request.notes}}Edit
+
+ {% else %} + + {% endif %} +
+
+
@@ -242,7 +316,7 @@

Users in Al
-

Messages from System Administrators

+

Notifications

{{notes.count}}
@@ -281,6 +355,16 @@

Messages fr $(document).ready(function () { var guage_data = {{ guage_data | safe }}; drawGauges(guage_data); + + $('#allocation_change_table').DataTable({ + lengthMenu: [5, 10, 20, 50, 100], + "pageLength": 5, + "ordering": false, + 'aoColumnDefs': [{ + 'bSortable': false, + 'aTargets': ['nosort'] + }] + }); }); function drawGauges(guage_data) { diff --git a/coldfront/core/allocation/urls.py b/coldfront/core/allocation/urls.py index 19925f8d4..21a6d685c 100644 --- a/coldfront/core/allocation/urls.py +++ b/coldfront/core/allocation/urls.py @@ -8,20 +8,32 @@ allocation_views.AllocationCreateView.as_view(), name='allocation-create'), path('/', allocation_views.AllocationDetailView.as_view(), name='allocation-detail'), + path('change-request//', allocation_views.AllocationChangeDetailView.as_view(), + name='allocation-change-detail'), path('/activate-request', allocation_views.AllocationActivateRequestView.as_view(), name='allocation-activate-request'), path('/deny-request', allocation_views.AllocationDenyRequestView.as_view(), name='allocation-deny-request'), + path('/activate-change-request', allocation_views.AllocationChangeActivateView.as_view(), + name='allocation-activate-change'), + path('/deny-change-request', allocation_views.AllocationChangeDenyView.as_view(), + name='allocation-deny-change'), + path('/delete-attribute-change', allocation_views.AllocationChangeDeleteAttributeView.as_view(), + name='allocation-attribute-change-delete'), path('/add-users', allocation_views.AllocationAddUsersView.as_view(), name='allocation-add-users'), path('/remove-users', allocation_views.AllocationRemoveUsersView.as_view(), name='allocation-remove-users'), path('request-list', allocation_views.AllocationRequestListView.as_view(), name='allocation-request-list'), + path('change-list', allocation_views.AllocationChangeListView.as_view(), + name='allocation-change-list'), path('/renew', allocation_views.AllocationRenewView.as_view(), name='allocation-renew'), path('/allocationattribute/add', allocation_views.AllocationAttributeCreateView.as_view(), name='allocation-attribute-add'), + path('/change-request', + allocation_views.AllocationChangeView.as_view(), name='allocation-change'), path('/allocationattribute/delete', allocation_views.AllocationAttributeDeleteView.as_view(), name='allocation-attribute-delete'), path('allocation-invoice-list', allocation_views.AllocationInvoiceListView.as_view(), diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 22dc20edb..116dadeed 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -26,6 +26,10 @@ from coldfront.core.allocation.forms import (AllocationAccountForm, AllocationAddUserForm, AllocationAttributeDeleteForm, + AllocationChangeForm, + AllocationChangeNoteForm, + AllocationAttributeChangeForm, + AllocationAttributeUpdateForm, AllocationForm, AllocationInvoiceNoteDeleteForm, AllocationInvoiceUpdateForm, @@ -36,6 +40,9 @@ from coldfront.core.allocation.models import (Allocation, AllocationAccount, AllocationAttribute, AllocationAttributeType, + AllocationChangeRequest, + AllocationChangeStatusChoice, + AllocationAttributeChangeRequest, AllocationStatusChoice, AllocationUser, AllocationUserNote, @@ -56,6 +63,8 @@ 'ALLOCATION_ENABLE_ALLOCATION_RENEWAL', True) ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings( 'ALLOCATION_DEFAULT_ALLOCATION_LENGTH', 365) +ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = import_from_settings( + 'ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT', True) EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) if EMAIL_ENABLED: @@ -131,6 +140,8 @@ def get_context_data(self, **kwargs): attributes = [attribute for attribute in allocation_obj.allocationattribute_set.filter( allocation_attribute_type__is_private=False)] + allocation_changes = allocation_obj.allocationchangerequest_set.all().order_by('-pk') + guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: @@ -160,6 +171,7 @@ def get_context_data(self, **kwargs): context['guage_data'] = guage_data context['attributes_with_usage'] = attributes_with_usage context['attributes'] = attributes + context['allocation_changes'] = allocation_changes # Can the user update the project? if self.request.user.is_superuser: @@ -193,7 +205,8 @@ def get(self, request, *args, **kwargs): 'status': allocation_obj.status, 'end_date': allocation_obj.end_date, 'start_date': allocation_obj.start_date, - 'description': allocation_obj.description + 'description': allocation_obj.description, + 'is_changeable': allocation_obj.is_changeable, } form = AllocationUpdateForm(initial=initial_data) @@ -216,7 +229,8 @@ def post(self, request, *args, **kwargs): 'status': allocation_obj.status, 'end_date': allocation_obj.end_date, 'start_date': allocation_obj.start_date, - 'description': allocation_obj.description + 'description': allocation_obj.description, + 'is_changeable': allocation_obj.is_changeable, } form = AllocationUpdateForm(request.POST, initial=initial_data) @@ -225,6 +239,7 @@ def post(self, request, *args, **kwargs): end_date = form_data.get('end_date') start_date = form_data.get('start_date') description = form_data.get('description') + is_changeable = form_data.get('is_changeable') allocation_obj.description = description allocation_obj.save() @@ -241,6 +256,7 @@ def post(self, request, *args, **kwargs): new_status = form_data.get('status').name allocation_obj.status = form_data.get('status') + allocation_obj.is_changeable = is_changeable allocation_obj.save() if EMAIL_ENABLED: @@ -596,6 +612,11 @@ def form_valid(self, form): quantity=quantity, status=allocation_status_obj ) + + if ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT: + allocation_obj.is_changeable = True + allocation_obj.save() + allocation_obj.resources.add(resource_obj) if ALLOCATION_ACCOUNT_ENABLED and allocation_account and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING: @@ -1163,7 +1184,6 @@ def get(self, request, pk): EMAIL_SENDER, email_receiver_list ) - print(email_receiver_list) return HttpResponseRedirect(reverse('allocation-request-list')) @@ -1592,3 +1612,780 @@ def test_func(self): def get_queryset(self): return AllocationAccount.objects.filter(user=self.request.user) + + +class AllocationChangeDetailView(LoginRequiredMixin, UserPassesTestMixin, FormView): + formset_class = AllocationAttributeUpdateForm + template_name = 'allocation/allocation_change_detail.html' + + def test_func(self): + """ UserPassesTestMixin Tests""" + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm('allocation.can_view_all_allocations'): + return True + + allocation_change_obj = get_object_or_404( + AllocationChangeRequest, pk=self.kwargs.get('pk')) + + if allocation_change_obj.allocation.project.pi == self.request.user: + return True + + if allocation_change_obj.allocation.project.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + return True + + user_can_access_project = allocation_change_obj.allocation.project.projectuser_set.filter( + user=self.request.user, status__name__in=['Active', 'New', ]).exists() + + user_can_access_allocation = allocation_change_obj.allocation.allocationuser_set.filter( + user=self.request.user, status__name__in=['Active', ]).exists() + + if user_can_access_project and user_can_access_allocation: + return True + + return False + + + def get_allocation_attributes_to_change(self, allocation_change_obj): + attributes_to_change = allocation_change_obj.allocationattributechangerequest_set.all() + + attributes_to_change = [ + + {'change_pk': attribute_change.pk, + 'attribute_pk': attribute_change.allocation_attribute.pk, + 'name': attribute_change.allocation_attribute.allocation_attribute_type.name, + 'value': attribute_change.allocation_attribute.value, + 'new_value': attribute_change.new_value, + } + + for attribute_change in attributes_to_change + ] + + return attributes_to_change + + def get_context_data(self, **kwargs): + context = {} + + allocation_change_obj = get_object_or_404( + AllocationChangeRequest, pk=self.kwargs.get('pk')) + + + allocation_attributes_to_change = self.get_allocation_attributes_to_change( + allocation_change_obj) + + if allocation_attributes_to_change: + formset = formset_factory(self.formset_class, max_num=len( + allocation_attributes_to_change)) + formset = formset( + initial=allocation_attributes_to_change, prefix='attributeform') + context['formset'] = formset + + context['allocation_change'] = allocation_change_obj + context['attribute_changes'] = allocation_attributes_to_change + + return context + + def get(self, request, *args, **kwargs): + + allocation_change_obj = get_object_or_404( + AllocationChangeRequest, pk=self.kwargs.get('pk')) + + allocation_change_form = AllocationChangeForm( + initial={'justification': allocation_change_obj.justification, + 'end_date_extension': allocation_change_obj.end_date_extension}) + allocation_change_form.fields['justification'].disabled = True + if allocation_change_obj.status.name != 'Pending': + allocation_change_form.fields['end_date_extension'].disabled = True + if allocation_change_obj.allocation.project.pi == self.request.user: + allocation_change_form.fields['end_date_extension'].disabled = True + + note_form = AllocationChangeNoteForm( + initial={'notes': allocation_change_obj.notes}) + + context = self.get_context_data() + + context['allocation_change_form'] = allocation_change_form + context['note_form'] = note_form + return render(request, self.template_name, context) + + def post(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + if not self.request.user.is_superuser: + messages.error( + request, 'You do not have permission to update an allocation change request') + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + + allocation_change_obj = get_object_or_404( + AllocationChangeRequest, pk=pk) + allocation_change_form = AllocationChangeForm(request.POST, + initial={'justification': allocation_change_obj.justification, + 'end_date_extension': allocation_change_obj.end_date_extension}) + + allocation_attributes_to_change = self.get_allocation_attributes_to_change( + allocation_change_obj) + + if allocation_attributes_to_change: + formset = formset_factory(self.formset_class, max_num=len( + allocation_attributes_to_change)) + formset = formset( + request.POST, initial=allocation_attributes_to_change, prefix='attributeform') + + note_form = AllocationChangeNoteForm( + request.POST, initial={'notes': allocation_change_obj.notes}) + + if note_form.is_valid(): + notes = note_form.cleaned_data.get('notes') + + if request.POST.get('choice') == 'approve': + if allocation_attributes_to_change: + if allocation_change_form.is_valid() and formset.is_valid(): + allocation_change_obj.notes = notes + + allocation_change_status_active_obj = AllocationChangeStatusChoice.objects.get( + name='Approved') + + allocation_change_obj.status = allocation_change_status_active_obj + + form_data = allocation_change_form.cleaned_data + + if form_data.get('end_date_extension') != allocation_change_obj.end_date_extension: + allocation_change_obj.end_date_extension = form_data.get('end_date_extension') + + new_end_date = allocation_change_obj.allocation.end_date + relativedelta( + days=allocation_change_obj.end_date_extension) + + allocation_change_obj.allocation.end_date = new_end_date + allocation_change_obj.allocation.save() + + allocation_change_obj.save() + + for entry in formset: + formset_data = entry.cleaned_data + new_value = formset_data.get('new_value') + + attribute_change = AllocationAttributeChangeRequest.objects.get(pk=formset_data.get('change_pk')) + + if new_value != attribute_change.new_value: + attribute_change.new_value = new_value + attribute_change.save() + + attribute_change_list = allocation_change_obj.allocationattributechangerequest_set.all() + + for attribute_change in attribute_change_list: + attribute_change.allocation_attribute.value = attribute_change.new_value + attribute_change.allocation_attribute.save() + + messages.success(request, 'Allocation change request to {} has been APPROVED for {} {} ({})'.format( + allocation_change_obj.allocation.get_parent_resource, + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username) + ) + + resource_name = allocation_change_obj.allocation.get_parent_resource + domain_url = get_domain_url(self.request) + allocation_url = '{}{}'.format(domain_url, reverse( + 'allocation-detail', kwargs={'pk': allocation_change_obj.allocation.pk})) + + if EMAIL_ENABLED: + template_context = { + 'center_name': EMAIL_CENTER_NAME, + 'resource': resource_name, + 'allocation_url': allocation_url, + 'signature': EMAIL_SIGNATURE, + 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL + } + + email_receiver_list = [] + + for allocation_user in allocation_change_obj.allocation.allocationuser_set.exclude(status__name__in=['Removed', 'Error']): + allocation_activate_user.send( + sender=self.__class__, allocation_user_pk=allocation_user.pk) + if allocation_user.allocation.project.projectuser_set.get(user=allocation_user.user).enable_notifications: + email_receiver_list.append(allocation_user.user.email) + + send_email_template( + 'Allocation Change Approved', + 'email/allocation_change_approved.txt', + template_context, + EMAIL_SENDER, + email_receiver_list + ) + + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + else: + if allocation_change_form.is_valid(): + form_data = allocation_change_form.cleaned_data + selected_extension = form_data.get('end_date_extension') + + if selected_extension != 0: + allocation_change_obj.notes = notes + + allocation_change_status_active_obj = AllocationChangeStatusChoice.objects.get( + name='Approved') + allocation_change_obj.status = allocation_change_status_active_obj + + if selected_extension != allocation_change_obj.end_date_extension: + allocation_change_obj.end_date_extension = selected_extension + + new_end_date = allocation_change_obj.allocation.end_date + relativedelta( + days=allocation_change_obj.end_date_extension) + allocation_change_obj.allocation.end_date = new_end_date + + allocation_change_obj.allocation.save() + allocation_change_obj.save() + + messages.success(request, 'Allocation change request to {} has been APPROVED for {} {} ({})'.format( + allocation_change_obj.allocation.get_parent_resource, + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username) + ) + + resource_name = allocation_change_obj.allocation.get_parent_resource + domain_url = get_domain_url(self.request) + allocation_url = '{}{}'.format(domain_url, reverse( + 'allocation-detail', kwargs={'pk': allocation_change_obj.allocation.pk})) + + if EMAIL_ENABLED: + template_context = { + 'center_name': EMAIL_CENTER_NAME, + 'resource': resource_name, + 'allocation_url': allocation_url, + 'signature': EMAIL_SIGNATURE, + 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL + } + + email_receiver_list = [] + + for allocation_user in allocation_change_obj.allocation.allocationuser_set.exclude(status__name__in=['Removed', 'Error']): + allocation_activate_user.send( + sender=self.__class__, allocation_user_pk=allocation_user.pk) + if allocation_user.allocation.project.projectuser_set.get(user=allocation_user.user).enable_notifications: + email_receiver_list.append(allocation_user.user.email) + + send_email_template( + 'Allocation Change Approved', + 'email/allocation_change_approved.txt', + template_context, + EMAIL_SENDER, + email_receiver_list + ) + + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + + else: + messages.error(request, 'You must make a change to the allocation.') + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + + else: + for error in allocation_change_form.errors: + messages.error(request, error) + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + + + if request.POST.get('choice') == 'deny': + allocation_change_obj.notes = notes + + allocation_change_status_denied_obj = AllocationChangeStatusChoice.objects.get( + name='Denied') + + allocation_change_obj.status = allocation_change_status_denied_obj + allocation_change_obj.save() + + messages.success(request, 'Allocation change request to {} has been DENIED for {} {} ({})'.format( + allocation_change_obj.allocation.resources.first(), + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username) + ) + + resource_name = allocation_change_obj.allocation.get_parent_resource + domain_url = get_domain_url(self.request) + allocation_url = '{}{}'.format(domain_url, reverse( + 'allocation-detail', kwargs={'pk': allocation_change_obj.allocation.pk})) + + if EMAIL_ENABLED: + template_context = { + 'center_name': EMAIL_CENTER_NAME, + 'resource': resource_name, + 'allocation_url': allocation_url, + 'signature': EMAIL_SIGNATURE, + 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL + } + + email_receiver_list = [] + for allocation_user in allocation_change_obj.allocation.allocationuser_set.exclude(status__name__in=['Removed', 'Error']): + allocation_remove_user.send( + sender=self.__class__, allocation_user_pk=allocation_user.pk) + if allocation_user.allocation.project.projectuser_set.get(user=allocation_user.user).enable_notifications: + email_receiver_list.append(allocation_user.user.email) + + send_email_template( + 'Allocation Change Denied', + 'email/allocation_change_denied.txt', + template_context, + EMAIL_SENDER, + email_receiver_list + ) + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + + if request.POST.get('choice') == 'update': + if allocation_change_obj.status.name != 'Pending': + allocation_change_obj.notes = notes + allocation_change_obj.save() + + messages.success( + request, 'Allocation change request updated!') + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + else: + if allocation_attributes_to_change: + if allocation_change_form.is_valid() and formset.is_valid(): + allocation_change_obj.notes = notes + + form_data = allocation_change_form.cleaned_data + + if form_data.get('end_date_extension') != allocation_change_obj.end_date_extension: + allocation_change_obj.end_date_extension = form_data.get('end_date_extension') + + + for entry in formset: + formset_data = entry.cleaned_data + new_value = formset_data.get('new_value') + + attribute_change = AllocationAttributeChangeRequest.objects.get(pk=formset_data.get('change_pk')) + + if new_value != attribute_change.new_value: + attribute_change.new_value = new_value + attribute_change.save() + + allocation_change_obj.save() + + messages.success( + request, 'Allocation change request updated!') + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + else: + attribute_errors = "" + for error in allocation_change_form.errors: + messages.error(request, error) + for error in formset.errors: + if error: attribute_errors += error.get('__all__') + messages.error(request, attribute_errors) + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + else: + if allocation_change_form.is_valid(): + form_data = allocation_change_form.cleaned_data + selected_extension = form_data.get('end_date_extension') + + if selected_extension != 0: + allocation_change_obj.notes = notes + + if selected_extension != allocation_change_obj.end_date_extension: + allocation_change_obj.end_date_extension = selected_extension + + allocation_change_obj.save() + + messages.success( + request, 'Allocation change request updated!') + + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + + else: + messages.error(request, 'You must make a change to the allocation.') + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + else: + for error in allocation_change_form.errors: + messages.error(request, error) + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + + else: + allocation_change_form = AllocationChangeForm( + initial={'justification': allocation_change_obj.justification}) + allocation_change_form.fields['justification'].disabled = True + + context = self.get_context_data() + + context['note_form'] = note_form + context['allocation_change_form'] = allocation_change_form + return render(request, self.template_name, context) + + +class AllocationChangeListView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): + template_name = 'allocation/allocation_change_list.html' + login_url = '/' + + def test_func(self): + """ UserPassesTestMixin Tests""" + + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm('allocation.can_review_allocation_requests'): + return True + + messages.error( + self.request, 'You do not have permission to review allocation requests.') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + allocation_change_list = AllocationChangeRequest.objects.filter( + status__name__in=['Pending', ]) + context['allocation_change_list'] = allocation_change_list + context['PROJECT_ENABLE_PROJECT_REVIEW'] = PROJECT_ENABLE_PROJECT_REVIEW + return context + + +class AllocationChangeView(LoginRequiredMixin, UserPassesTestMixin, FormView): + formset_class = AllocationAttributeChangeForm + template_name = 'allocation/allocation_change.html' + + def test_func(self): + """ UserPassesTestMixin Tests""" + if self.request.user.is_superuser: + return True + + allocation_obj = get_object_or_404( + Allocation, pk=self.kwargs.get('pk')) + + if allocation_obj.project.pi == self.request.user: + return True + + if allocation_obj.project.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + return True + + messages.error( + self.request, 'You do not have permission to request changes to this allocation.') + + def dispatch(self, request, *args, **kwargs): + allocation_obj = get_object_or_404( + Allocation, pk=self.kwargs.get('pk')) + + if allocation_obj.project.needs_review: + messages.error( + request, 'You cannot request a change to this allocation because you have to review your project first.') + return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + + if allocation_obj.project.status.name not in ['Active', 'New', ]: + messages.error( + request, 'You cannot request a change to an allocation in an archived project.') + return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + + if allocation_obj.is_locked: + messages.error( + request, 'You cannot request a change to a locked allocation.') + return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + + if allocation_obj.status.name not in ['Active', 'New', 'Renewal Requested', 'Payment Pending', 'Payment Requested', 'Paid']: + messages.error(request, 'You cannot request a change to an allocation with status {}.'.format( + allocation_obj.status.name)) + return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + + return super().dispatch(request, *args, **kwargs) + + def get_allocation_attributes_to_change(self, allocation_obj): + attributes_to_change = allocation_obj.allocationattribute_set.filter( + allocation_attribute_type__is_changeable=True) + + attributes_to_change = [ + + {'pk': attribute.pk, + 'name': attribute.allocation_attribute_type.name, + 'value': attribute.value, + } + + for attribute in attributes_to_change + ] + + return attributes_to_change + + def get(self, request, *args, **kwargs): + context = {} + + allocation_obj = get_object_or_404( + Allocation, pk=self.kwargs.get('pk')) + + form = AllocationChangeForm(**self.get_form_kwargs()) + context['form'] = form + + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) + + if allocation_attributes_to_change: + formset = formset_factory(self.formset_class, max_num=len( + allocation_attributes_to_change)) + formset = formset( + initial=allocation_attributes_to_change, prefix='attributeform') + context['formset'] = formset + context['allocation'] = allocation_obj + context['attributes'] = allocation_attributes_to_change + return render(request, self.template_name, context) + + def post(self, request, *args, **kwargs): + change_requested = False + attribute_changes_to_make = set({}) + + pk = self.kwargs.get('pk') + allocation_obj = get_object_or_404(Allocation, pk=pk) + + form = AllocationChangeForm(**self.get_form_kwargs()) + + allocation_attributes_to_change = self.get_allocation_attributes_to_change( + allocation_obj) + + if allocation_attributes_to_change: + formset = formset_factory(self.formset_class, max_num=len( + allocation_attributes_to_change)) + formset = formset( + request.POST, initial=allocation_attributes_to_change, prefix='attributeform') + + if form.is_valid() and formset.is_valid(): + form_data = form.cleaned_data + + if form_data.get('end_date_extension') != 0: change_requested = True + + for entry in formset: + formset_data = entry.cleaned_data + + new_value = formset_data.get('new_value') + + if new_value != "": + change_requested = True + + allocation_attribute = AllocationAttribute.objects.get(pk=formset_data.get('pk')) + attribute_changes_to_make.add((allocation_attribute, new_value)) + + if change_requested == True: + + end_date_extension = form_data.get('end_date_extension') + justification = form_data.get('justification') + + change_request_status_obj = AllocationChangeStatusChoice.objects.get( + name='Pending') + + allocation_change_request_obj = AllocationChangeRequest.objects.create( + allocation=allocation_obj, + end_date_extension=end_date_extension, + justification=justification, + status=change_request_status_obj + ) + + for attribute in attribute_changes_to_make: + attribute_change_request_obj = AllocationAttributeChangeRequest.objects.create( + allocation_change_request=allocation_change_request_obj, + allocation_attribute=attribute[0], + new_value=attribute[1] + ) + messages.success( + request, 'Allocation change request successfully submitted.') + + return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + + else: + messages.error(request, 'You must request a change.') + return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + + else: + attribute_errors = "" + for error in form.errors: + messages.error(request, error) + for error in formset.errors: + if error: attribute_errors += error.get('__all__') + messages.error(request, attribute_errors) + return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + else: + if form.is_valid(): + form_data = form.cleaned_data + + if form_data.get('end_date_extension') != 0: + + end_date_extension = form_data.get('end_date_extension') + justification = form_data.get('justification') + + change_request_status_obj = AllocationChangeStatusChoice.objects.get( + name='Pending') + + allocation_change_request_obj = AllocationChangeRequest.objects.create( + allocation=allocation_obj, + end_date_extension=end_date_extension, + justification=justification, + status=change_request_status_obj + ) + messages.success( + request, 'Allocation change request successfully submitted.') + + return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + + else: + messages.error(request, 'You must request a change.') + return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + + else: + for error in form.errors: + messages.error(request, error) + return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + + +class AllocationChangeActivateView(LoginRequiredMixin, UserPassesTestMixin, View): + login_url = '/' + + def test_func(self): + """ UserPassesTestMixin Tests""" + + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm('allocation.can_review_allocation_requests'): + return True + + messages.error( + self.request, 'You do not have permission to approve an allocation change.') + + def get(self, request, pk): + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=pk) + + allocation_change_status_active_obj = AllocationChangeStatusChoice.objects.get( + name='Approved') + + allocation_change_obj.status = allocation_change_status_active_obj + + if allocation_change_obj.end_date_extension != 0: + new_end_date = allocation_change_obj.allocation.end_date + relativedelta( + days=allocation_change_obj.end_date_extension) + + allocation_change_obj.allocation.end_date = new_end_date + + allocation_change_obj.allocation.save() + allocation_change_obj.save() + + attribute_change_list = allocation_change_obj.allocationattributechangerequest_set.all() + + for attribute_change in attribute_change_list: + attribute_change.allocation_attribute.value = attribute_change.new_value + attribute_change.allocation_attribute.save() + + messages.success(request, 'Allocation change request to {} has been APPROVED for {} {} ({})'.format( + allocation_change_obj.allocation.get_parent_resource, + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username) + ) + + resource_name = allocation_change_obj.allocation.get_parent_resource + domain_url = get_domain_url(self.request) + allocation_url = '{}{}'.format(domain_url, reverse( + 'allocation-detail', kwargs={'pk': allocation_change_obj.allocation.pk})) + + if EMAIL_ENABLED: + template_context = { + 'center_name': EMAIL_CENTER_NAME, + 'resource': resource_name, + 'allocation_url': allocation_url, + 'signature': EMAIL_SIGNATURE, + 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL + } + + email_receiver_list = [] + + for allocation_user in allocation_change_obj.allocation.allocationuser_set.exclude(status__name__in=['Removed', 'Error']): + allocation_activate_user.send( + sender=self.__class__, allocation_user_pk=allocation_user.pk) + if allocation_user.allocation.project.projectuser_set.get(user=allocation_user.user).enable_notifications: + email_receiver_list.append(allocation_user.user.email) + + send_email_template( + 'Allocation Change Approved', + 'email/allocation_change_approved.txt', + template_context, + EMAIL_SENDER, + email_receiver_list + ) + + return HttpResponseRedirect(reverse('allocation-change-list')) + + +class AllocationChangeDenyView(LoginRequiredMixin, UserPassesTestMixin, View): + login_url = '/' + + def test_func(self): + """ UserPassesTestMixin Tests""" + + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm('allocation.can_review_allocation_requests'): + return True + + messages.error( + self.request, 'You do not have permission to deny an allocation change.') + + def get(self, request, pk): + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=pk) + + allocation_change_status_denied_obj = AllocationChangeStatusChoice.objects.get( + name='Denied') + + allocation_change_obj.status = allocation_change_status_denied_obj + allocation_change_obj.save() + + messages.success(request, 'Allocation change request to {} has been DENIED for {} {} ({})'.format( + allocation_change_obj.allocation.resources.first(), + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username) + ) + + resource_name = allocation_change_obj.allocation.get_parent_resource + domain_url = get_domain_url(self.request) + allocation_url = '{}{}'.format(domain_url, reverse( + 'allocation-detail', kwargs={'pk': allocation_change_obj.allocation.pk})) + + if EMAIL_ENABLED: + template_context = { + 'center_name': EMAIL_CENTER_NAME, + 'resource': resource_name, + 'allocation_url': allocation_url, + 'signature': EMAIL_SIGNATURE, + 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL + } + + email_receiver_list = [] + for allocation_user in allocation_change_obj.allocation.allocationuser_set.exclude(status__name__in=['Removed', 'Error']): + allocation_remove_user.send( + sender=self.__class__, allocation_user_pk=allocation_user.pk) + if allocation_user.allocation.project.projectuser_set.get(user=allocation_user.user).enable_notifications: + email_receiver_list.append(allocation_user.user.email) + + send_email_template( + 'Allocation Change Denied', + 'email/allocation_change_denied.txt', + template_context, + EMAIL_SENDER, + email_receiver_list + ) + return HttpResponseRedirect(reverse('allocation-change-list')) + + +class AllocationChangeDeleteAttributeView(LoginRequiredMixin, UserPassesTestMixin, View): + login_url = '/' + + def test_func(self): + """ UserPassesTestMixin Tests""" + + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm('allocation.can_review_allocation_requests'): + return True + + messages.error( + self.request, 'You do not have permission to update an allocation change request.') + + def get(self, request, pk): + allocation_attribute_change_obj = get_object_or_404(AllocationAttributeChangeRequest, pk=pk) + allocation_change_pk = allocation_attribute_change_obj.allocation_change_request.pk + + allocation_attribute_change_obj.delete() + + messages.success( + request, 'Allocation attribute change request successfully deleted.') + return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': allocation_change_pk})) diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index 04674956c..9281606b4 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -382,7 +382,7 @@

-

Messages from System Administrators

{{project.projectusermessage_set.count}} +

Notifications

{{project.projectusermessage_set.count}}
{% if project.projectusermessage_set.all %} diff --git a/coldfront/templates/common/navbar_admin.html b/coldfront/templates/common/navbar_admin.html index 9872e4a74..31d8f689b 100644 --- a/coldfront/templates/common/navbar_admin.html +++ b/coldfront/templates/common/navbar_admin.html @@ -8,6 +8,8 @@ Project Reviews Allocation Requests + + Allocation Change Requests Grant Report
diff --git a/coldfront/templates/email/allocation_change_approved.txt b/coldfront/templates/email/allocation_change_approved.txt new file mode 100644 index 000000000..6052b46c8 --- /dev/null +++ b/coldfront/templates/email/allocation_change_approved.txt @@ -0,0 +1,10 @@ +Dear {{center_name}} user, + +Your allocation change request for {{resource}} has been approved. The requested changes are now active. + +To view your allocation's information, please go to {{allocation_url}} +If you are a student or collaborator under this project, you are receiving this notice as a courtesy. If you would like +to opt out of future notifications, instructions can be found here: {{opt_out_instruction_url}} + +Thank you, +{{signature}} diff --git a/coldfront/templates/email/allocation_change_denied.txt b/coldfront/templates/email/allocation_change_denied.txt new file mode 100644 index 000000000..633b29d97 --- /dev/null +++ b/coldfront/templates/email/allocation_change_denied.txt @@ -0,0 +1,10 @@ +Dear {{center_name}} user, + +Your allocation change request for {{resource}} has been denied. + +Please login to view a message from the system administrators pertaining to your allocation change request: {{allocation_url}} +If you are a student or collaborator under this project, you are receiving this notice as a courtesy. If you would like +to opt out of future notifications, instructions can be found here: {{opt_out_instruction_url}} + +Thank you, +{{signature}} diff --git a/docs/pages/config.md b/docs/pages/config.md index 997983293..f2300f7ed 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -86,6 +86,8 @@ The following settings are ColdFront specific settings related to the core appli | PROJECT_ENABLE_PROJECT_REVIEW | Enable or disable project reviews. Default True| | ALLOCATION_ENABLE_ALLOCATION_RENEWAL | Enable or disable allocation renewals. Default True | | ALLOCATION_DEFAULT_ALLOCATION_LENGTH | Default number of days an allocation is active for. Default 365 | +| ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT | Enable or disable allocation change requests. Default True | +| ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS | List of days users can request extensions in an allocation change request. Default 30,60,90 | | ALLOCATION_ACCOUNT_ENABLED | Allow user to select account name for allocation. Default False | | INVOICE_ENABLED | Enable or disable invoices. Default True | | ONDEMAND_URL | The URL to your Open OnDemand installation |