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 changes to an existing allocation using the form below. For each change + you must provide a justification. +
+ + + + +{% 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 %} + +{{ allocation_change.notes }}
+ {% endif %} +{{ allocation_change.notes }}
+ {% endif %} +{{ allocation_change.notes }}
+ {% endif %} +# | +Requested | +Project Title | +PI | +Resource | +Extension | +Actions | +
---|---|---|---|---|---|---|
{{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 %} + | +
Date Requested | +Status | +Notes | +Actions | +|||
---|---|---|---|---|---|---|
{{ change_request.modified|date:"M. d, Y" }} | + {% if change_request.status.name == 'Approved' %} +{{ change_request.status.name }} | + {% elif change_request.status.name == 'Denied' %} +{{ change_request.status.name }} | + {% else %} +{{ change_request.status.name }} | + {% endif %} + + {% if change_request.notes %} +{{change_request.notes}} | + {% else %} ++ {% endif %} + | Edit | +