Merge branch 'AIRAVATA-3126--Implement-compute-resource-reservation-addition'
diff --git a/airavata/model/appcatalog/groupresourceprofile/ttypes.py b/airavata/model/appcatalog/groupresourceprofile/ttypes.py
index e2841da..d5b8e18 100644
--- a/airavata/model/appcatalog/groupresourceprofile/ttypes.py
+++ b/airavata/model/appcatalog/groupresourceprofile/ttypes.py
@@ -118,6 +118,132 @@
         return not (self == other)
 
 
+class ComputeResourceReservation(object):
+    """
+    Attributes:
+     - reservationId
+     - reservationName
+     - queueNames
+     - startTime
+     - endTime
+    """
+
+    thrift_spec = (
+        None,  # 0
+        (1, TType.STRING, 'reservationId', 'UTF8', "DO_NOT_SET_AT_CLIENTS", ),  # 1
+        (2, TType.STRING, 'reservationName', 'UTF8', None, ),  # 2
+        (3, TType.LIST, 'queueNames', (TType.STRING, 'UTF8', False), None, ),  # 3
+        (4, TType.I64, 'startTime', None, None, ),  # 4
+        (5, TType.I64, 'endTime', None, None, ),  # 5
+    )
+
+    def __init__(self, reservationId=thrift_spec[1][4], reservationName=None, queueNames=None, startTime=None, endTime=None,):
+        self.reservationId = reservationId
+        self.reservationName = reservationName
+        self.queueNames = queueNames
+        self.startTime = startTime
+        self.endTime = endTime
+
+    def read(self, iprot):
+        if iprot._fast_decode is not None and isinstance(iprot.trans, TTransport.CReadableTransport) and self.thrift_spec is not None:
+            iprot._fast_decode(self, iprot, (self.__class__, self.thrift_spec))
+            return
+        iprot.readStructBegin()
+        while True:
+            (fname, ftype, fid) = iprot.readFieldBegin()
+            if ftype == TType.STOP:
+                break
+            if fid == 1:
+                if ftype == TType.STRING:
+                    self.reservationId = iprot.readString().decode('utf-8') if sys.version_info[0] == 2 else iprot.readString()
+                else:
+                    iprot.skip(ftype)
+            elif fid == 2:
+                if ftype == TType.STRING:
+                    self.reservationName = iprot.readString().decode('utf-8') if sys.version_info[0] == 2 else iprot.readString()
+                else:
+                    iprot.skip(ftype)
+            elif fid == 3:
+                if ftype == TType.LIST:
+                    self.queueNames = []
+                    (_etype3, _size0) = iprot.readListBegin()
+                    for _i4 in range(_size0):
+                        _elem5 = iprot.readString().decode('utf-8') if sys.version_info[0] == 2 else iprot.readString()
+                        self.queueNames.append(_elem5)
+                    iprot.readListEnd()
+                else:
+                    iprot.skip(ftype)
+            elif fid == 4:
+                if ftype == TType.I64:
+                    self.startTime = iprot.readI64()
+                else:
+                    iprot.skip(ftype)
+            elif fid == 5:
+                if ftype == TType.I64:
+                    self.endTime = iprot.readI64()
+                else:
+                    iprot.skip(ftype)
+            else:
+                iprot.skip(ftype)
+            iprot.readFieldEnd()
+        iprot.readStructEnd()
+
+    def write(self, oprot):
+        if oprot._fast_encode is not None and self.thrift_spec is not None:
+            oprot.trans.write(oprot._fast_encode(self, (self.__class__, self.thrift_spec)))
+            return
+        oprot.writeStructBegin('ComputeResourceReservation')
+        if self.reservationId is not None:
+            oprot.writeFieldBegin('reservationId', TType.STRING, 1)
+            oprot.writeString(self.reservationId.encode('utf-8') if sys.version_info[0] == 2 else self.reservationId)
+            oprot.writeFieldEnd()
+        if self.reservationName is not None:
+            oprot.writeFieldBegin('reservationName', TType.STRING, 2)
+            oprot.writeString(self.reservationName.encode('utf-8') if sys.version_info[0] == 2 else self.reservationName)
+            oprot.writeFieldEnd()
+        if self.queueNames is not None:
+            oprot.writeFieldBegin('queueNames', TType.LIST, 3)
+            oprot.writeListBegin(TType.STRING, len(self.queueNames))
+            for iter6 in self.queueNames:
+                oprot.writeString(iter6.encode('utf-8') if sys.version_info[0] == 2 else iter6)
+            oprot.writeListEnd()
+            oprot.writeFieldEnd()
+        if self.startTime is not None:
+            oprot.writeFieldBegin('startTime', TType.I64, 4)
+            oprot.writeI64(self.startTime)
+            oprot.writeFieldEnd()
+        if self.endTime is not None:
+            oprot.writeFieldBegin('endTime', TType.I64, 5)
+            oprot.writeI64(self.endTime)
+            oprot.writeFieldEnd()
+        oprot.writeFieldStop()
+        oprot.writeStructEnd()
+
+    def validate(self):
+        if self.reservationId is None:
+            raise TProtocolException(message='Required field reservationId is unset!')
+        if self.reservationName is None:
+            raise TProtocolException(message='Required field reservationName is unset!')
+        if self.queueNames is None:
+            raise TProtocolException(message='Required field queueNames is unset!')
+        if self.startTime is None:
+            raise TProtocolException(message='Required field startTime is unset!')
+        if self.endTime is None:
+            raise TProtocolException(message='Required field endTime is unset!')
+        return
+
+    def __repr__(self):
+        L = ['%s=%r' % (key, value)
+             for key, value in self.__dict__.items()]
+        return '%s(%s)' % (self.__class__.__name__, ', '.join(L))
+
+    def __eq__(self, other):
+        return isinstance(other, self.__class__) and self.__dict__ == other.__dict__
+
+    def __ne__(self, other):
+        return not (self == other)
+
+
 class GroupComputeResourcePreference(object):
     """
     Attributes:
@@ -139,6 +265,7 @@
      - sshAccountProvisioner
      - groupSSHAccountProvisionerConfigs
      - sshAccountProvisionerAdditionalInfo
+     - reservations
     """
 
     thrift_spec = (
@@ -161,9 +288,10 @@
         (16, TType.STRING, 'sshAccountProvisioner', 'UTF8', None, ),  # 16
         (17, TType.LIST, 'groupSSHAccountProvisionerConfigs', (TType.STRUCT, (GroupAccountSSHProvisionerConfig, GroupAccountSSHProvisionerConfig.thrift_spec), False), None, ),  # 17
         (18, TType.STRING, 'sshAccountProvisionerAdditionalInfo', 'UTF8', None, ),  # 18
+        (19, TType.LIST, 'reservations', (TType.STRUCT, (ComputeResourceReservation, ComputeResourceReservation.thrift_spec), False), None, ),  # 19
     )
 
-    def __init__(self, computeResourceId=None, groupResourceProfileId=thrift_spec[2][4], overridebyAiravata=thrift_spec[3][4], loginUserName=None, preferredJobSubmissionProtocol=None, preferredDataMovementProtocol=None, preferredBatchQueue=None, scratchLocation=None, allocationProjectNumber=None, resourceSpecificCredentialStoreToken=None, usageReportingGatewayId=None, qualityOfService=None, reservation=None, reservationStartTime=None, reservationEndTime=None, sshAccountProvisioner=None, groupSSHAccountProvisionerConfigs=None, sshAccountProvisionerAdditionalInfo=None,):
+    def __init__(self, computeResourceId=None, groupResourceProfileId=thrift_spec[2][4], overridebyAiravata=thrift_spec[3][4], loginUserName=None, preferredJobSubmissionProtocol=None, preferredDataMovementProtocol=None, preferredBatchQueue=None, scratchLocation=None, allocationProjectNumber=None, resourceSpecificCredentialStoreToken=None, usageReportingGatewayId=None, qualityOfService=None, reservation=None, reservationStartTime=None, reservationEndTime=None, sshAccountProvisioner=None, groupSSHAccountProvisionerConfigs=None, sshAccountProvisionerAdditionalInfo=None, reservations=None,):
         self.computeResourceId = computeResourceId
         self.groupResourceProfileId = groupResourceProfileId
         self.overridebyAiravata = overridebyAiravata
@@ -182,6 +310,7 @@
         self.sshAccountProvisioner = sshAccountProvisioner
         self.groupSSHAccountProvisionerConfigs = groupSSHAccountProvisionerConfigs
         self.sshAccountProvisionerAdditionalInfo = sshAccountProvisionerAdditionalInfo
+        self.reservations = reservations
 
     def read(self, iprot):
         if iprot._fast_decode is not None and isinstance(iprot.trans, TTransport.CReadableTransport) and self.thrift_spec is not None:
@@ -275,11 +404,11 @@
             elif fid == 17:
                 if ftype == TType.LIST:
                     self.groupSSHAccountProvisionerConfigs = []
-                    (_etype3, _size0) = iprot.readListBegin()
-                    for _i4 in range(_size0):
-                        _elem5 = GroupAccountSSHProvisionerConfig()
-                        _elem5.read(iprot)
-                        self.groupSSHAccountProvisionerConfigs.append(_elem5)
+                    (_etype10, _size7) = iprot.readListBegin()
+                    for _i11 in range(_size7):
+                        _elem12 = GroupAccountSSHProvisionerConfig()
+                        _elem12.read(iprot)
+                        self.groupSSHAccountProvisionerConfigs.append(_elem12)
                     iprot.readListEnd()
                 else:
                     iprot.skip(ftype)
@@ -288,6 +417,17 @@
                     self.sshAccountProvisionerAdditionalInfo = iprot.readString().decode('utf-8') if sys.version_info[0] == 2 else iprot.readString()
                 else:
                     iprot.skip(ftype)
+            elif fid == 19:
+                if ftype == TType.LIST:
+                    self.reservations = []
+                    (_etype16, _size13) = iprot.readListBegin()
+                    for _i17 in range(_size13):
+                        _elem18 = ComputeResourceReservation()
+                        _elem18.read(iprot)
+                        self.reservations.append(_elem18)
+                    iprot.readListEnd()
+                else:
+                    iprot.skip(ftype)
             else:
                 iprot.skip(ftype)
             iprot.readFieldEnd()
@@ -365,14 +505,21 @@
         if self.groupSSHAccountProvisionerConfigs is not None:
             oprot.writeFieldBegin('groupSSHAccountProvisionerConfigs', TType.LIST, 17)
             oprot.writeListBegin(TType.STRUCT, len(self.groupSSHAccountProvisionerConfigs))
-            for iter6 in self.groupSSHAccountProvisionerConfigs:
-                iter6.write(oprot)
+            for iter19 in self.groupSSHAccountProvisionerConfigs:
+                iter19.write(oprot)
             oprot.writeListEnd()
             oprot.writeFieldEnd()
         if self.sshAccountProvisionerAdditionalInfo is not None:
             oprot.writeFieldBegin('sshAccountProvisionerAdditionalInfo', TType.STRING, 18)
             oprot.writeString(self.sshAccountProvisionerAdditionalInfo.encode('utf-8') if sys.version_info[0] == 2 else self.sshAccountProvisionerAdditionalInfo)
             oprot.writeFieldEnd()
+        if self.reservations is not None:
+            oprot.writeFieldBegin('reservations', TType.LIST, 19)
+            oprot.writeListBegin(TType.STRUCT, len(self.reservations))
+            for iter20 in self.reservations:
+                iter20.write(oprot)
+            oprot.writeListEnd()
+            oprot.writeFieldEnd()
         oprot.writeFieldStop()
         oprot.writeStructEnd()
 
@@ -447,10 +594,10 @@
             elif fid == 4:
                 if ftype == TType.LIST:
                     self.allowedBatchQueues = []
-                    (_etype10, _size7) = iprot.readListBegin()
-                    for _i11 in range(_size7):
-                        _elem12 = iprot.readString().decode('utf-8') if sys.version_info[0] == 2 else iprot.readString()
-                        self.allowedBatchQueues.append(_elem12)
+                    (_etype24, _size21) = iprot.readListBegin()
+                    for _i25 in range(_size21):
+                        _elem26 = iprot.readString().decode('utf-8') if sys.version_info[0] == 2 else iprot.readString()
+                        self.allowedBatchQueues.append(_elem26)
                     iprot.readListEnd()
                 else:
                     iprot.skip(ftype)
@@ -479,8 +626,8 @@
         if self.allowedBatchQueues is not None:
             oprot.writeFieldBegin('allowedBatchQueues', TType.LIST, 4)
             oprot.writeListBegin(TType.STRING, len(self.allowedBatchQueues))
-            for iter13 in self.allowedBatchQueues:
-                oprot.writeString(iter13.encode('utf-8') if sys.version_info[0] == 2 else iter13)
+            for iter27 in self.allowedBatchQueues:
+                oprot.writeString(iter27.encode('utf-8') if sys.version_info[0] == 2 else iter27)
             oprot.writeListEnd()
             oprot.writeFieldEnd()
         oprot.writeFieldStop()
@@ -730,33 +877,33 @@
             elif fid == 4:
                 if ftype == TType.LIST:
                     self.computePreferences = []
-                    (_etype17, _size14) = iprot.readListBegin()
-                    for _i18 in range(_size14):
-                        _elem19 = GroupComputeResourcePreference()
-                        _elem19.read(iprot)
-                        self.computePreferences.append(_elem19)
+                    (_etype31, _size28) = iprot.readListBegin()
+                    for _i32 in range(_size28):
+                        _elem33 = GroupComputeResourcePreference()
+                        _elem33.read(iprot)
+                        self.computePreferences.append(_elem33)
                     iprot.readListEnd()
                 else:
                     iprot.skip(ftype)
             elif fid == 5:
                 if ftype == TType.LIST:
                     self.computeResourcePolicies = []
-                    (_etype23, _size20) = iprot.readListBegin()
-                    for _i24 in range(_size20):
-                        _elem25 = ComputeResourcePolicy()
-                        _elem25.read(iprot)
-                        self.computeResourcePolicies.append(_elem25)
+                    (_etype37, _size34) = iprot.readListBegin()
+                    for _i38 in range(_size34):
+                        _elem39 = ComputeResourcePolicy()
+                        _elem39.read(iprot)
+                        self.computeResourcePolicies.append(_elem39)
                     iprot.readListEnd()
                 else:
                     iprot.skip(ftype)
             elif fid == 6:
                 if ftype == TType.LIST:
                     self.batchQueueResourcePolicies = []
-                    (_etype29, _size26) = iprot.readListBegin()
-                    for _i30 in range(_size26):
-                        _elem31 = BatchQueueResourcePolicy()
-                        _elem31.read(iprot)
-                        self.batchQueueResourcePolicies.append(_elem31)
+                    (_etype43, _size40) = iprot.readListBegin()
+                    for _i44 in range(_size40):
+                        _elem45 = BatchQueueResourcePolicy()
+                        _elem45.read(iprot)
+                        self.batchQueueResourcePolicies.append(_elem45)
                     iprot.readListEnd()
                 else:
                     iprot.skip(ftype)
@@ -800,22 +947,22 @@
         if self.computePreferences is not None:
             oprot.writeFieldBegin('computePreferences', TType.LIST, 4)
             oprot.writeListBegin(TType.STRUCT, len(self.computePreferences))
-            for iter32 in self.computePreferences:
-                iter32.write(oprot)
+            for iter46 in self.computePreferences:
+                iter46.write(oprot)
             oprot.writeListEnd()
             oprot.writeFieldEnd()
         if self.computeResourcePolicies is not None:
             oprot.writeFieldBegin('computeResourcePolicies', TType.LIST, 5)
             oprot.writeListBegin(TType.STRUCT, len(self.computeResourcePolicies))
-            for iter33 in self.computeResourcePolicies:
-                iter33.write(oprot)
+            for iter47 in self.computeResourcePolicies:
+                iter47.write(oprot)
             oprot.writeListEnd()
             oprot.writeFieldEnd()
         if self.batchQueueResourcePolicies is not None:
             oprot.writeFieldBegin('batchQueueResourcePolicies', TType.LIST, 6)
             oprot.writeListBegin(TType.STRUCT, len(self.batchQueueResourcePolicies))
-            for iter34 in self.batchQueueResourcePolicies:
-                iter34.write(oprot)
+            for iter48 in self.batchQueueResourcePolicies:
+                iter48.write(oprot)
             oprot.writeListEnd()
             oprot.writeFieldEnd()
         if self.creationTime is not None:
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputePreference.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputePreference.vue
index 794a552..bea36f0 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputePreference.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputePreference.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div class="has-fixed-footer">
     <div class="row">
       <div class="col">
         <h1 class="h4 mb-4">
@@ -7,10 +7,9 @@
             v-if="localGroupResourceProfile"
             class="group-resource-profile-name text-muted text-uppercase"
           >
-            <i
-              class="fa fa-server"
-              aria-hidden="true"
-            ></i> {{ localGroupResourceProfile.groupResourceProfileName }}</div>
+            <i class="fa fa-server" aria-hidden="true"></i>
+            {{ localGroupResourceProfile.groupResourceProfileName }}
+          </div>
           {{ computeResource.hostName }}
         </h1>
       </div>
@@ -22,7 +21,9 @@
             <b-form-group
               label="Login Username"
               label-for="login-username"
-              :invalid-feedback="validationFeedback.loginUserName.invalidFeedback"
+              :invalid-feedback="
+                validationFeedback.loginUserName.invalidFeedback
+              "
               :state="validationFeedback.loginUserName.state"
             >
               <b-form-input
@@ -42,16 +43,22 @@
               <ssh-credential-selector
                 v-model="data.resourceSpecificCredentialStoreToken"
                 v-if="localGroupResourceProfile"
-                :null-option-default-credential-token="localGroupResourceProfile.defaultCredentialStoreToken"
-                :null-option-disabled="!localGroupResourceProfile.defaultCredentialStoreToken"
+                :null-option-default-credential-token="
+                  localGroupResourceProfile.defaultCredentialStoreToken
+                "
+                :null-option-disabled="
+                  !localGroupResourceProfile.defaultCredentialStoreToken
+                "
               >
                 <template
                   slot="null-option-label"
                   slot-scope="nullOptionLabelScope"
                 >
                   <span v-if="nullOptionLabelScope.defaultCredentialSummary">
-                    Use the default SSH credential for {{ localGroupResourceProfile.groupResourceProfileName }} ({{
-                    nullOptionLabelScope.defaultCredentialSummary.description }})
+                    Use the default SSH credential for
+                    {{ localGroupResourceProfile.groupResourceProfileName }} ({{
+                      nullOptionLabelScope.defaultCredentialSummary.description
+                    }})
                   </span>
                   <span v-else>
                     Select a SSH credential
@@ -73,7 +80,9 @@
             <b-form-group
               label="Scratch Location"
               label-for="scratch-location"
-              :invalid-feedback="validationFeedback.scratchLocation.invalidFeedback"
+              :invalid-feedback="
+                validationFeedback.scratchLocation.invalidFeedback
+              "
               :state="validationFeedback.scratchLocation.state"
             >
               <b-form-input
@@ -104,15 +113,27 @@
                 :key="batchQueue.queueName"
               >
                 <b-form-checkbox
-                  :checked="localComputeResourcePolicy.allowedBatchQueues.includes(batchQueue.queueName)"
+                  :checked="
+                    localComputeResourcePolicy.allowedBatchQueues.includes(
+                      batchQueue.queueName
+                    )
+                  "
                   @input="batchQueueChecked(batchQueue, $event)"
                 >
                   {{ batchQueue.queueName }}
                 </b-form-checkbox>
                 <batch-queue-resource-policy
-                  v-if="localComputeResourcePolicy.allowedBatchQueues.includes(batchQueue.queueName)"
+                  v-if="
+                    localComputeResourcePolicy.allowedBatchQueues.includes(
+                      batchQueue.queueName
+                    )
+                  "
                   :batch-queue="batchQueue"
-                  :value="localBatchQueueResourcePolicies.find(pol => pol.queuename === batchQueue.queueName)"
+                  :value="
+                    localBatchQueueResourcePolicies.find(
+                      pol => pol.queuename === batchQueue.queueName
+                    )
+                  "
                   @input="updatedBatchQueueResourcePolicy(batchQueue, $event)"
                   @valid="recordValidBatchQueueResourcePolicy(batchQueue)"
                   @invalid="recordInvalidBatchQueueResourcePolicy(batchQueue)"
@@ -124,24 +145,33 @@
       </div>
     </div>
     <div class="row">
-      <div class="col d-flex justify-content-end">
-        <b-button
-          variant="primary"
-          @click="save"
-          :disabled="!valid"
-        >Save</b-button>
-        <b-button
-          class="ml-2"
-          variant="danger"
-          @click="remove"
-        >Delete</b-button>
-        <b-button
-          class="ml-2"
-          variant="secondary"
-          @click="cancel"
-        >Cancel</b-button>
+      <div class="col">
+        <div class="card">
+          <div class="card-body">
+            <compute-resource-reservation-list
+              :reservations="data.reservations"
+              :queues="queueNames"
+              @added="addReservation"
+              @deleted="deleteReservation"
+              @updated="updateReservation"
+              @valid="reservationsInvalid = false"
+              @invalid="reservationsInvalid = true"
+            />
+          </div>
+        </div>
       </div>
     </div>
+    <div class="fixed-footer">
+        <b-button variant="primary" @click="save" :disabled="!valid"
+          >Save</b-button
+        >
+        <b-button class="ml-2" variant="danger" @click="remove"
+          >Delete</b-button
+        >
+        <b-button class="ml-2" variant="secondary" @click="cancel"
+          >Cancel</b-button
+        >
+    </div>
   </div>
 </template>
 
@@ -149,6 +179,7 @@
 import DjangoAiravataAPI from "django-airavata-api";
 import BatchQueueResourcePolicy from "./BatchQueueResourcePolicy.vue";
 import SSHCredentialSelector from "../../credentials/SSHCredentialSelector.vue";
+import ComputeResourceReservationList from "./ComputeResourceReservationList";
 
 import { models, services, errors } from "django-airavata-api";
 import {
@@ -161,7 +192,8 @@
   name: "compute-preference",
   components: {
     BatchQueueResourcePolicy,
-    "ssh-credential-selector": SSHCredentialSelector
+    "ssh-credential-selector": SSHCredentialSelector,
+    ComputeResourceReservationList
   },
   props: {
     id: {
@@ -232,7 +264,8 @@
         jobSubmissionInterfaces: []
       },
       validationErrors: null,
-      invalidBatchQueueResourcePolicies: []
+      invalidBatchQueueResourcePolicies: [],
+      reservationsInvalid: false
     };
   },
   computed: {
@@ -248,13 +281,17 @@
     valid() {
       return (
         this.allowedInvalidBatchQueueResourcePolicies.length === 0 &&
-        Object.keys(this.groupComputeResourceValidation).length === 0
+        Object.keys(this.groupComputeResourceValidation).length === 0 &&
+        !this.reservationsInvalid
       );
     },
     allowedInvalidBatchQueueResourcePolicies() {
       return this.invalidBatchQueueResourcePolicies.filter(queueName =>
         this.localComputeResourcePolicy.allowedBatchQueues.includes(queueName)
       );
+    },
+    queueNames() {
+      return this.computeResource.batchQueues.map(bq => bq.queueName);
     }
   },
   mixins: [mixins.VModelMixin],
@@ -436,6 +473,22 @@
       } else {
         this.$emit("invalid");
       }
+    },
+    addReservation(reservation) {
+      this.data.reservations.push(reservation);
+      this.data.reservations.sort((a, b) => a.startTime < b.startTime ? -1 : 1);
+    },
+    deleteReservation(reservation) {
+      const reservationIndex = this.data.reservations.findIndex(
+        r => r.key === reservation.key
+      );
+      this.data.reservations.splice(reservationIndex, 1);
+    },
+    updateReservation(reservation) {
+      const reservationIndex = this.data.reservations.findIndex(
+        r => r.key === reservation.key
+      );
+      this.data.reservations.splice(reservationIndex, 1, reservation);
     }
   },
   beforeRouteEnter: function(to, from, next) {
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationEditor.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationEditor.vue
new file mode 100644
index 0000000..04b9a93
--- /dev/null
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationEditor.vue
@@ -0,0 +1,158 @@
+<template>
+  <b-form>
+    <b-form-group
+      label="Reservation name"
+      label-for="reservation-name"
+      :invalid-feedback="nameValidationFeedback"
+      :state="nameValidationState"
+    >
+      <b-form-input
+        id="reservation-name"
+        v-model="data.reservationName"
+        type="text"
+        @input="nameInputBegins = true"
+        :state="nameValidationState"
+      />
+    </b-form-group>
+    <b-form-group
+      label="Start Time"
+      label-for="start-time"
+      :invalid-feedback="getValidationFeedback('startTime')"
+      :state="getValidationState('startTime')"
+    >
+      <datetime
+        id="start-time"
+        type="datetime"
+        :value="startTimeAsString"
+        input-class="form-control"
+        :format="{
+          year: 'numeric',
+          month: '2-digit',
+          day: 'numeric',
+          hour: 'numeric',
+          minute: '2-digit',
+          timeZoneName: 'short'
+        }"
+        :phrases="{ ok: 'Continue', cancel: 'Exit' }"
+        :hour-step="1"
+        :minute-step="30"
+        :week-start="7"
+        use12-hour
+        auto
+        @input="data.startTime = stringToDate($event)"
+      ></datetime>
+    </b-form-group>
+    <b-form-group
+      label="End Time"
+      label-for="end-time"
+      :invalid-feedback="getValidationFeedback('endTime')"
+      :state="getValidationState('endTime')"
+    >
+      <datetime
+        id="end-time"
+        type="datetime"
+        :value="endTimeAsString"
+        :input-class="{
+          'form-control': true,
+          'is-invalid': getValidationState('endTime')
+        }"
+        :format="{
+          year: 'numeric',
+          month: '2-digit',
+          day: 'numeric',
+          hour: 'numeric',
+          minute: '2-digit',
+          timeZoneName: 'short'
+        }"
+        :phrases="{ ok: 'Continue', cancel: 'Exit' }"
+        :hour-step="1"
+        :minute-step="30"
+        :week-start="7"
+        :min-datetime="startTimeAsString"
+        use12-hour
+        auto
+        @input="data.endTime = stringToDate($event)"
+      ></datetime>
+    </b-form-group>
+    <b-form-group
+      label="Queues"
+      label-for="queues"
+      :invalid-feedback="getValidationFeedback('queueNames')"
+      :state="getValidationState('queueNames')"
+    >
+      <b-form-checkbox-group
+        id="queues"
+        v-model="data.queueNames"
+        :options="queueNameOptions"
+        :state="getValidationState('queueNames')"
+      />
+    </b-form-group>
+  </b-form>
+</template>
+
+<script>
+import { mixins, utils } from "django-airavata-common-ui";
+import { Datetime } from "vue-datetime";
+import "vue-datetime/dist/vue-datetime.css";
+
+export default {
+  name: "compute-resource-reservation-editor",
+  mixins: [mixins.VModelMixin],
+  components: {
+    datetime: Datetime
+  },
+  props: {
+    queues: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      nameInputBegins: false
+    };
+  },
+  created() {
+    this.$on("input", this.valuesChanged);
+  },
+  computed: {
+    startTimeAsString() {
+      return this.data.startTime.toISOString();
+    },
+    endTimeAsString() {
+      return this.data.endTime.toISOString();
+    },
+    nameValidationFeedback() {
+      return this.getValidationFeedback("reservationName");
+    },
+    nameValidationState() {
+      if (this.nameInputBegins === false) {
+        return null;
+      }
+      return this.getValidationState("reservationName");
+    },
+    queueNameOptions() {
+      return this.queues.slice().sort();
+    }
+  },
+  methods: {
+    stringToDate(datetimeString) {
+      return new Date(datetimeString);
+    },
+    getValidationFeedback: function(properties) {
+      return utils.getProperty(this.data.validate(), properties);
+    },
+    getValidationState: function(properties) {
+      return this.getValidationFeedback(properties) ? "invalid" : null;
+    },
+    valuesChanged() {
+      const validationResults = this.data.validate();
+      if (Object.keys(validationResults).length === 0) {
+        this.$emit("valid");
+      } else {
+        this.$emit("invalid");
+      }
+    }
+  }
+};
+</script>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationList.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationList.vue
new file mode 100644
index 0000000..757efb8
--- /dev/null
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationList.vue
@@ -0,0 +1,251 @@
+<template>
+  <list-layout
+    @add-new-item="addNewReservation"
+    :items="decoratedReservations"
+    title="Reservations"
+    new-item-button-text="New Reservation"
+    :new-button-disabled="readonly"
+  >
+    <template slot="additional-buttons">
+      <delete-button
+        class="mr-2"
+        @delete="deleteAllExpiredReservations"
+        label="Delete All Expired"
+        :disabled="expiredReservations.length === 0"
+      >
+        Are you sure you want to delete all expired reservations?
+      </delete-button>
+    </template>
+    <template slot="new-item-editor">
+      <b-card v-if="showNewItemEditor" title="New Reservation">
+        <compute-resource-reservation-editor
+          v-model="newReservation"
+          :queues="queues"
+          @valid="
+            newReservationValid = true;
+            validate();
+          "
+          @invalid="
+            newReservationValid = false;
+            validate();
+          "
+        />
+        <div class="row">
+          <div class="col">
+            <b-button
+              variant="primary"
+              @click="saveNewReservation"
+              :disabled="isSaveDisabled"
+            >
+              Add
+            </b-button>
+            <b-button variant="secondary" @click="cancelNewReservation">
+              Cancel
+            </b-button>
+          </div>
+        </div>
+      </b-card>
+    </template>
+    <template slot="item-list" slot-scope="slotProps">
+      <b-table
+        hover
+        :fields="fields"
+        :items="slotProps.items"
+      >
+        <template slot="reservationName" slot-scope="data">
+          {{ data.value }}
+          <b-badge v-if="data.item.isExpired">Expired</b-badge>
+          <b-badge v-if="data.item.isActive" variant="success">Active</b-badge>
+          <b-badge v-if="data.item.isUpcoming" variant="info">Upcoming</b-badge>
+        </template>
+        <template slot="queueNames" slot-scope="data">
+          <ul v-for="queueName in data.item.queueNames" :key="queueName">
+            <li>{{ queueName }}</li>
+          </ul>
+        </template>
+        <template slot="action" slot-scope="data">
+          <b-link
+            v-if="!readonly"
+            class="action-link"
+            @click="toggleDetails(data)"
+            :disabled="isReservationInvalid(data.item.key)"
+          >
+            Edit
+            <i class="fa fa-edit" aria-hidden="true"></i>
+          </b-link>
+          <delete-link
+            v-if="!readonly"
+            class="action-link"
+            @delete="deleteReservation(data.item)"
+          >
+            Are you sure you want to delete reservation
+            {{ data.item.reservationName }}?
+          </delete-link>
+        </template>
+        <template slot="row-details" slot-scope="row">
+          <b-card>
+            <compute-resource-reservation-editor
+              :value="row.item"
+              @input="updatedReservation"
+              :queues="queues"
+              @valid="removeInvalidReservation(row.item.key)"
+              @invalid="recordInvalidReservation(row.item.key)"
+            />
+            <b-button
+              size="sm"
+              @click="toggleDetails(row)"
+              :disabled="isReservationInvalid(row.item.key)"
+              >Close</b-button
+            >
+          </b-card>
+        </template>
+      </b-table>
+    </template>
+  </list-layout>
+</template>
+
+<script>
+import { models } from "django-airavata-api";
+import { components, layouts, utils } from "django-airavata-common-ui";
+import ComputeResourceReservationEditor from "./ComputeResourceReservationEditor";
+
+export default {
+  name: "compute-resource-reservation-list",
+  components: {
+    "delete-link": components.DeleteLink,
+    "human-date": components.HumanDate,
+    "list-layout": layouts.ListLayout,
+    ComputeResourceReservationEditor,
+    "delete-button": components.DeleteButton
+  },
+  props: {
+    reservations: {
+      type: Array,
+      required: true
+    },
+    queues: {
+      type: Array,
+      required: true
+    },
+    readonly: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      showingDetails: {},
+      showNewItemEditor: false,
+      newReservation: null,
+      newReservationValid: false,
+      invalidReservations: [] // list of ComputeResourceReservation.key
+    };
+  },
+  computed: {
+    fields() {
+      return [
+        {
+          label: "Name",
+          key: "reservationName"
+        },
+        {
+          label: "Queues",
+          key: "queueNames"
+        },
+        {
+          label: "Start Time",
+          key: "startTime",
+          formatter: value =>
+            utils.dateFormatters.dateTimeInMinutesWithTimeZone.format(value)
+        },
+        {
+          label: "End Time",
+          key: "endTime",
+          formatter: value =>
+            utils.dateFormatters.dateTimeInMinutesWithTimeZone.format(value)
+        },
+        {
+          label: "Action",
+          key: "action"
+        }
+      ];
+    },
+    decoratedReservations() {
+      return this.reservations
+        ? this.reservations.map(res => {
+            const resClone = res.clone();
+            resClone._showDetails = this.showingDetails[resClone.key];
+            return resClone;
+          })
+        : [];
+    },
+    isSaveDisabled() {
+      return !this.newReservationValid;
+    },
+    valid() {
+      return (
+        (!this.showNewItemEditor || this.newReservationValid) &&
+        this.invalidReservations.length === 0
+      );
+    },
+    expiredReservations() {
+      return this.reservations
+        ? this.reservations.filter(r => r.isExpired)
+        : [];
+    }
+  },
+  created() {},
+  methods: {
+    updatedReservation(newValue) {
+      this.$emit("updated", newValue);
+    },
+    toggleDetails(row) {
+      row.toggleDetails();
+      this.showingDetails[row.item.key] = !this.showingDetails[row.item.key];
+    },
+    deleteReservation(reservation) {
+      this.removeInvalidReservation(reservation.key);
+      this.$emit("deleted", reservation);
+    },
+    addNewReservation() {
+      this.newReservation = new models.ComputeResourceReservation();
+      this.newReservationValid = false;
+      this.newReservation.queueNames = this.queues.slice();
+      this.showNewItemEditor = true;
+    },
+    saveNewReservation() {
+      this.$emit("added", this.newReservation);
+      this.showNewItemEditor = false;
+    },
+    cancelNewReservation() {
+      this.showNewItemEditor = false;
+    },
+    recordInvalidReservation(reservationKey) {
+      if (this.invalidReservations.indexOf(reservationKey) < 0) {
+        this.invalidReservations.push(reservationKey);
+      }
+      this.validate();
+    },
+    removeInvalidReservation(reservationKey) {
+      const index = this.invalidReservations.indexOf(reservationKey);
+      if (index >= 0) {
+        this.invalidReservations.splice(index, 1);
+      }
+      this.validate();
+    },
+    isReservationInvalid(reservationKey) {
+      return this.invalidReservations.indexOf(reservationKey) >= 0;
+    },
+    validate() {
+      if (this.valid) {
+        this.$emit("valid");
+      } else {
+        this.$emit("invalid");
+      }
+    },
+    deleteAllExpiredReservations() {
+      this.expiredReservations.forEach(this.deleteReservation);
+    }
+  }
+};
+</script>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationsSummary.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationsSummary.vue
new file mode 100644
index 0000000..0cd71f2
--- /dev/null
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/ComputeResourceReservationsSummary.vue
@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <div v-if="expiredReservations.length > 0">
+      {{ expiredReservations.length }} expired
+    </div>
+    <div v-if="activeReservations.length > 0">
+      {{ activeReservations.length }} active ({{
+        activeReservationNames.join(", ")
+      }})
+    </div>
+    <div v-if="upcomingReservations.length > 0">
+      {{ upcomingReservations.length }} upcoming
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "compute-resource-reservations-summary",
+  props: {
+    reservations: {
+      type: Array,
+      required: true
+    }
+  },
+  computed: {
+    expiredReservations() {
+      return this.reservations
+        ? this.reservations.filter(r => r.isExpired)
+        : [];
+    },
+    activeReservations() {
+      return this.reservations ? this.reservations.filter(r => r.isActive) : [];
+    },
+    activeReservationNames() {
+      return this.activeReservations.map(r => r.reservationName);
+    },
+    upcomingReservations() {
+      return this.reservations
+        ? this.reservations.filter(r => r.isUpcoming)
+        : [];
+    }
+  }
+};
+</script>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/GroupComputeResourcePreference.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/GroupComputeResourcePreference.vue
index 7bca11c..1da5d22 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/GroupComputeResourcePreference.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/admin/group_resource_preferences/GroupComputeResourcePreference.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div class="has-fixed-footer">
     <div class="row">
       <div class="col">
         <h1 class="h4 mb-4">{{ title }}</h1>
@@ -10,11 +10,23 @@
         <div class="card">
           <div class="card-body">
             <b-form-group label="Name" label-for="profile-name">
-              <b-form-input id="profile-name" type="text" v-model="data.groupResourceProfileName" required placeholder="Name of this Group Resource Profile">
+              <b-form-input
+                id="profile-name"
+                type="text"
+                v-model="data.groupResourceProfileName"
+                required
+                placeholder="Name of this Group Resource Profile"
+              >
               </b-form-input>
             </b-form-group>
-            <b-form-group label="Default SSH Credential" label-for="default-credential-store-token">
-              <ssh-credential-selector id="default-credential-store-token" v-model="data.defaultCredentialStoreToken">
+            <b-form-group
+              label="Default SSH Credential"
+              label-for="default-credential-store-token"
+            >
+              <ssh-credential-selector
+                id="default-credential-store-token"
+                v-model="data.defaultCredentialStoreToken"
+              >
               </ssh-credential-selector>
             </b-form-group>
             <share-button ref="shareButton" :entity-id="id" />
@@ -22,30 +34,57 @@
         </div>
       </div>
     </div>
-    <list-layout :items="data.computePreferences" title="Compute Preferences" new-item-button-text="New Compute Preference"
-      @add-new-item="createComputePreference">
+    <list-layout
+      :items="data.computePreferences"
+      title="Compute Preferences"
+      new-item-button-text="New Compute Preference"
+      @add-new-item="createComputePreference"
+    >
       <template slot="item-list" slot-scope="slotProps">
-
-        <b-table hover :fields="computePreferencesFields" :items="slotProps.items" sort-by="computeResourceId">
+        <b-table
+          hover
+          :fields="computePreferencesFields"
+          :items="slotProps.items"
+          sort-by="computeResourceId"
+        >
           <template slot="policy" slot-scope="row">
-            <compute-resource-policy-summary :compute-resource-id="row.item.computeResourceId" :group-resource-profile="data" />
+            <compute-resource-policy-summary
+              :compute-resource-id="row.item.computeResourceId"
+              :group-resource-profile="data"
+            />
+          </template>
+          <template slot="reservations" slot-scope="row">
+            <compute-resource-reservations-summary :reservations="row.value" />
           </template>
           <template slot="action" slot-scope="row">
-            <router-link class="action-link" :to="{
+            <router-link
+              class="action-link"
+              :to="{
                 name: 'compute_preference',
                 params: {
                   value: row.item,
                   id: id,
                   host_id: row.item.computeResourceId,
                   groupResourceProfile: data,
-                  computeResourcePolicy: data.getComputeResourcePolicy(row.item.computeResourceId),
-                  batchQueueResourcePolicies: data.getBatchQueueResourcePolicies(row.item.computeResourceId)
+                  computeResourcePolicy: data.getComputeResourcePolicy(
+                    row.item.computeResourceId
+                  ),
+                  batchQueueResourcePolicies: data.getBatchQueueResourcePolicies(
+                    row.item.computeResourceId
+                  )
                 }
-              }">
+              }"
+            >
               Edit
               <i class="fa fa-edit" aria-hidden="true"></i>
             </router-link>
-            <a href="#" class="action-link text-danger" @click.prevent="removeComputePreference(row.item.computeResourceId)">
+            <a
+              href="#"
+              class="action-link text-danger"
+              @click.prevent="
+                removeComputePreference(row.item.computeResourceId)
+              "
+            >
               Delete
               <i class="fa fa-trash" aria-hidden="true"></i>
             </a>
@@ -53,15 +92,26 @@
         </b-table>
       </template>
     </list-layout>
-    <div class="row">
-      <div class="col d-flex justify-content-end">
-        <b-button variant="primary" @click="saveGroupResourceProfile">Save</b-button>
-        <b-button v-if="id" class="ml-2" variant="danger" @click="removeGroupResourceProfile">Delete</b-button>
-        <b-button class="ml-2" variant="secondary" @click="cancel">Cancel</b-button>
-      </div>
+    <div class="fixed-footer">
+      <b-button variant="primary" @click="saveGroupResourceProfile"
+        >Save</b-button
+      >
+      <b-button
+        v-if="id"
+        class="ml-2"
+        variant="danger"
+        @click="removeGroupResourceProfile"
+        >Delete</b-button
+      >
+      <b-button class="ml-2" variant="secondary" @click="cancel"
+        >Cancel</b-button
+      >
     </div>
-    <compute-resources-modal ref="modalSelectComputeResource" @selected="onSelectComputeResource"
-      :excluded-resource-ids="excludedComputeResourceIds" />
+    <compute-resources-modal
+      ref="modalSelectComputeResource"
+      @selected="onSelectComputeResource"
+      :excluded-resource-ids="excludedComputeResourceIds"
+    />
   </div>
 </template>
 
@@ -69,6 +119,7 @@
 import { components as comps, layouts } from "django-airavata-common-ui";
 import { models, services } from "django-airavata-api";
 import ComputeResourcePolicySummary from "./ComputeResourcePolicySummary.vue";
+import ComputeResourceReservationsSummary from "./ComputeResourceReservationsSummary.vue";
 import ComputeResourcesModal from "../ComputeResourcesModal.vue";
 import SSHCredentialSelector from "../../credentials/SSHCredentialSelector.vue";
 
@@ -88,9 +139,9 @@
   mounted: function() {
     if (this.id) {
       if (!this.value.groupResourceProfileId) {
-        services.GroupResourceProfileService
-          .retrieve({ lookup: this.id })
-          .then(grp => (this.data = grp));
+        services.GroupResourceProfileService.retrieve({ lookup: this.id }).then(
+          grp => (this.data = grp)
+        );
       }
     }
   },
@@ -119,6 +170,10 @@
           key: "policy" // custom rendering
         },
         {
+          label: "Reservations",
+          key: "reservations" // custom rendering
+        },
+        {
           label: "Action",
           key: "action"
         }
@@ -131,7 +186,8 @@
     "list-layout": layouts.ListLayout,
     ComputeResourcePolicySummary,
     ComputeResourcesModal,
-    "ssh-credential-selector": SSHCredentialSelector
+    "ssh-credential-selector": SSHCredentialSelector,
+    ComputeResourceReservationsSummary
   },
   computed: {
     excludedComputeResourceIds() {
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/gatewayprofile/StoragePreferenceList.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/gatewayprofile/StoragePreferenceList.vue
index 145d0e0..af2122c 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/gatewayprofile/StoragePreferenceList.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/gatewayprofile/StoragePreferenceList.vue
@@ -33,7 +33,7 @@
             Edit
             <i class="fa fa-edit" aria-hidden="true"></i>
           </b-link>
-          <delete-link v-if="!readonly" @delete="deleteStoragePreference(data.item.storageResourceId)">
+          <delete-link v-if="!readonly" class="action-link" @delete="deleteStoragePreference(data.item.storageResourceId)">
             Are you sure you want to delete the storage preference for {{ getStorageResourceName(data.item.storageResourceId) }}?
           </delete-link>
         </template>
diff --git a/django_airavata/apps/api/serializers.py b/django_airavata/apps/api/serializers.py
index 58b1470..2cf160f 100644
--- a/django_airavata/apps/api/serializers.py
+++ b/django_airavata/apps/api/serializers.py
@@ -27,6 +27,7 @@
     StoragePreference
 )
 from airavata.model.appcatalog.groupresourceprofile.ttypes import (
+    ComputeResourceReservation,
     GroupComputeResourcePreference,
     GroupResourceProfile
 )
@@ -567,9 +568,15 @@
     lastAccessTime = UTCPosixTimestampDateTimeField()
 
 
+class ComputeResourceReservationSerializer(
+        thrift_utils.create_serializer_class(ComputeResourceReservation)):
+    startTime = UTCPosixTimestampDateTimeField(allow_null=True)
+    endTime = UTCPosixTimestampDateTimeField(allow_null=True)
+
+
 class GroupComputeResourcePreferenceSerializer(
         thrift_utils.create_serializer_class(GroupComputeResourcePreference)):
-    pass
+    reservations = ComputeResourceReservationSerializer(many=True)
 
 
 class GroupResourceProfileSerializer(
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/index.js b/django_airavata/apps/api/static/django_airavata_api/js/index.js
index 5107811..a6be8d5 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/index.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/index.js
@@ -11,6 +11,7 @@
 import BatchQueueResourcePolicy from "./models/BatchQueueResourcePolicy";
 import CommandObject from "./models/CommandObject";
 import ComputeResourcePolicy from "./models/ComputeResourcePolicy";
+import ComputeResourceReservation from "./models/ComputeResourceReservation";
 import DataProduct from "./models/DataProduct";
 import DataType from "./models/DataType";
 import Experiment from "./models/Experiment";
@@ -68,6 +69,7 @@
   BatchQueueResourcePolicy,
   CommandObject,
   ComputeResourcePolicy,
+  ComputeResourceReservation,
   DataProduct,
   DataType,
   Experiment,
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/BaseModel.js b/django_airavata/apps/api/static/django_airavata_api/js/models/BaseModel.js
index f922b5f..de985a4 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/BaseModel.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/BaseModel.js
@@ -1,118 +1,145 @@
-import BaseEnum from './BaseEnum'
+import BaseEnum from "./BaseEnum";
 
 export default class BaseModel {
-
-    /**
-     * Create and optionally populate fields of a model instance.
-     * - fields: an Array of field definitions. Each field definition can either
-     *   be just the name of the field as a string, or an object with the
-     *   following properties:
-     *   - name (required)
-     *   - type (required: one of 'string', 'boolean', 'number', 'date', or a class reference)
-     *   - list (optional, boolean)
-     *   - default (optional, the default value to be used, if not specified then null is used)
-     * - data: a data object, typically a deserialized JSON response
-     */
-    constructor(fields, data={}){
-        fields.forEach(fieldDefinition => {
-            if (typeof fieldDefinition === 'string') {
-                this[fieldDefinition] = this.convertSimpleField(data[fieldDefinition], null);
-            } else { // fieldDefinition must be an object
-                let fieldName = fieldDefinition.name;
-                let fieldType = fieldDefinition.type;
-                let fieldIsList = typeof fieldDefinition.list !== 'undefined' ? fieldDefinition.list : false;
-                let fieldDefault = typeof fieldDefinition.default !== 'undefined' ? this.getDefaultValue(fieldDefinition.default) : null;
-                let fieldValue = data[fieldName];
-                if (fieldIsList) {
-                    this[fieldName] = fieldValue ? fieldValue.map(item => this.convertField(fieldType, item, fieldDefault)) : fieldDefault;
-                } else {
-                    this[fieldName] = this.convertField(fieldType, fieldValue, fieldDefault);
-                }
-            }
-        });
-    }
-
-    convertField(fieldType, fieldValue, fieldDefault) {
-        if (fieldValue === null || typeof fieldValue === 'undefined') {
-            return fieldDefault;
-        } else if (fieldType === 'string' || fieldType === 'boolean' || fieldType === 'number') {
-            return this.convertSimpleField(fieldValue, fieldDefault);
-        } else if (fieldType === 'date') {
-            return this.convertDateField(fieldValue, fieldDefault);
-        } else if (typeof fieldType === 'function') {
-            // Assume that it is another BaseModel class
-            return this.convertModelField(fieldType, fieldValue, fieldDefault);
-        }
-    }
-
-    convertSimpleField(fieldValue, fieldDefault) {
-        return typeof fieldValue !== 'undefined' ? fieldValue : fieldDefault;
-    }
-
-    convertDateField(fieldValue, fieldDefault) {
-        return typeof fieldValue !== 'undefined' ? new Date(fieldValue) : fieldDefault;
-    }
-
-    convertModelField(modelClass, fieldValue, fieldDefault) {
-        if (typeof fieldValue !== 'undefined') {
-            if (modelClass.prototype instanceof BaseEnum) {
-                // When cloning the fieldValue is an enum instance
-                if (fieldValue instanceof BaseEnum){
-                    return fieldValue;
-                }
-                let enumValue = null;
-                if (typeof fieldValue === 'string') {
-                    // convert by name if type is string
-                    enumValue = modelClass.byName(fieldValue);
-                } else {
-                    // Otherwise it is an integer that we need to convert to enum
-                    enumValue = modelClass.byValue(fieldValue);
-                }
-                if (!enumValue) {
-                    // enum wasn't found, construct an enum instance from the value
-                    return new BaseEnum(`Unknown value: ${fieldValue}`, fieldValue);
-                } else {
-                    return enumValue;
-                }
-            } else if (fieldValue instanceof modelClass) {
-                // No conversion necessary, just return the fieldValue
-                return fieldValue;
-            } else {
-                return new modelClass(fieldValue);
-            }
-        }
-        return fieldDefault;
-    }
-
-    getDefaultValue(fieldDefault) {
-        if (typeof fieldDefault === 'function') {
-            return fieldDefault();
+  /**
+   * Create and optionally populate fields of a model instance.
+   * - fields: an Array of field definitions. Each field definition can either
+   *   be just the name of the field as a string, or an object with the
+   *   following properties:
+   *   - name (required)
+   *   - type (required: one of 'string', 'boolean', 'number', 'date', or a class reference)
+   *   - list (optional, boolean)
+   *   - default (optional, the default value to be used, if not specified then null is used)
+   * - data: a data object, typically a deserialized JSON response
+   */
+  constructor(fields, data = {}) {
+    fields.forEach(fieldDefinition => {
+      if (typeof fieldDefinition === "string") {
+        this[fieldDefinition] = this.convertSimpleField(
+          data[fieldDefinition],
+          null
+        );
+      } else {
+        // fieldDefinition must be an object
+        let fieldName = fieldDefinition.name;
+        let fieldType = fieldDefinition.type;
+        let fieldIsList =
+          typeof fieldDefinition.list !== "undefined"
+            ? fieldDefinition.list
+            : false;
+        let fieldDefault =
+          typeof fieldDefinition.default !== "undefined"
+            ? this.getDefaultValue(fieldDefinition.default)
+            : null;
+        let fieldValue = data[fieldName];
+        if (fieldIsList) {
+          this[fieldName] = fieldValue
+            ? fieldValue.map(item =>
+                this.convertField(fieldType, item, fieldDefault)
+              )
+            : fieldDefault;
         } else {
-            return fieldDefault;
+          this[fieldName] = this.convertField(
+            fieldType,
+            fieldValue,
+            fieldDefault
+          );
         }
-    }
+      }
+    });
+  }
 
-    static defaultNewInstance(classRef) {
-        return () => new classRef();
+  convertField(fieldType, fieldValue, fieldDefault) {
+    if (fieldValue === null || typeof fieldValue === "undefined") {
+      return fieldDefault;
+    } else if (
+      fieldType === "string" ||
+      fieldType === "boolean" ||
+      fieldType === "number"
+    ) {
+      return this.convertSimpleField(fieldValue, fieldDefault);
+    } else if (fieldType === "date") {
+      return this.convertDateField(fieldValue, fieldDefault);
+    } else if (typeof fieldType === "function") {
+      // Assume that it is another BaseModel class
+      return this.convertModelField(fieldType, fieldValue, fieldDefault);
     }
+  }
 
-    /**
-     * Override to provide validation. If there are validation errors this
-     * method should return a dictionary where keys are property names and
-     * values are an array of error messages.
-     */
-    validate() {
-        return null;
-    }
+  convertSimpleField(fieldValue, fieldDefault) {
+    return typeof fieldValue !== "undefined" ? fieldValue : fieldDefault;
+  }
 
-    isEmpty(value) {
-        return value === null || (typeof value === 'string' && value.trim() === '');
-    }
+  convertDateField(fieldValue, fieldDefault) {
+    return typeof fieldValue !== "undefined"
+      ? new Date(fieldValue)
+      : fieldDefault;
+  }
 
-    /**
-     * Return a fully deep cloned instance of this instance.
-     */
-    clone() {
-        return new this.constructor(this);
+  convertModelField(modelClass, fieldValue, fieldDefault) {
+    if (typeof fieldValue !== "undefined") {
+      if (modelClass.prototype instanceof BaseEnum) {
+        // When cloning the fieldValue is an enum instance
+        if (fieldValue instanceof BaseEnum) {
+          return fieldValue;
+        }
+        let enumValue = null;
+        if (typeof fieldValue === "string") {
+          // convert by name if type is string
+          enumValue = modelClass.byName(fieldValue);
+        } else {
+          // Otherwise it is an integer that we need to convert to enum
+          enumValue = modelClass.byValue(fieldValue);
+        }
+        if (!enumValue) {
+          // enum wasn't found, construct an enum instance from the value
+          return new BaseEnum(`Unknown value: ${fieldValue}`, fieldValue);
+        } else {
+          return enumValue;
+        }
+      } else if (fieldValue instanceof modelClass) {
+        // No conversion necessary, just return the fieldValue
+        return fieldValue;
+      } else {
+        return new modelClass(fieldValue);
+      }
     }
+    return fieldDefault;
+  }
+
+  getDefaultValue(fieldDefault) {
+    if (typeof fieldDefault === "function") {
+      return fieldDefault();
+    } else {
+      return fieldDefault;
+    }
+  }
+
+  static defaultNewInstance(classRef) {
+    return () => new classRef();
+  }
+
+  /**
+   * Override to provide validation. If there are validation errors this
+   * method should return a dictionary where keys are property names and
+   * values are an array of error messages.
+   */
+  validate() {
+    return null;
+  }
+
+  isEmpty(value) {
+    return (
+      value === null ||
+      (typeof value === "string" && value.trim() === "") ||
+      (value instanceof Array && value.length === 0)
+    );
+  }
+
+  /**
+   * Return a fully deep cloned instance of this instance.
+   */
+  clone() {
+    return new this.constructor(this);
+  }
 }
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/ComputeResourceReservation.js b/django_airavata/apps/api/static/django_airavata_api/js/models/ComputeResourceReservation.js
new file mode 100644
index 0000000..9d3110b
--- /dev/null
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/ComputeResourceReservation.js
@@ -0,0 +1,65 @@
+import BaseModel from "./BaseModel";
+import uuidv4 from "uuid/v4";
+
+function currentTimeTopOfHour() {
+  const d = new Date();
+  d.setMinutes(0);
+  d.setSeconds(0);
+  d.setMilliseconds(0);
+  return d;
+}
+const FIELDS = [
+  "reservationId",
+  "reservationName",
+  {
+    name: "queueNames",
+    type: "string",
+    list: true
+  },
+  {
+    name: "startTime",
+    type: Date,
+    default: () => currentTimeTopOfHour()
+  },
+  {
+    name: "endTime",
+    type: Date,
+    default: () => currentTimeTopOfHour()
+  }
+];
+
+export default class ComputeResourceReservation extends BaseModel {
+  constructor(data = {}) {
+    super(FIELDS, data);
+    this._key = data.key ? data.key : uuidv4();
+  }
+  get key() {
+    return this._key;
+  }
+  validate() {
+    let validationResults = {};
+    if (this.isEmpty(this.reservationName)) {
+      validationResults["reservationName"] =
+        "Please provide the name of this reservation.";
+    }
+    if (this.startTime > this.endTime) {
+      validationResults["endTime"] = "End time must be later than start time.";
+    }
+    if (this.isEmpty(this.queueNames)) {
+      validationResults["queueNames"] = "Please select at least one queue.";
+    }
+    return validationResults;
+  }
+  get isExpired() {
+    const now = new Date();
+    return now > this.endTime;
+  }
+  get isActive() {
+    const now = new Date();
+    return this.startTime < now && now < this.endTime;
+  }
+  get isUpcoming() {
+    const now = new Date();
+    return now < this.startTime;
+  }
+}
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/GroupComputeResourcePreference.js b/django_airavata/apps/api/static/django_airavata_api/js/models/GroupComputeResourcePreference.js
index facaa85..6cba727 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/GroupComputeResourcePreference.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/GroupComputeResourcePreference.js
@@ -1,4 +1,5 @@
 import BaseModel from "./BaseModel";
+import ComputeResourceReservation from "./ComputeResourceReservation";
 
 const FIELDS = [
   "computeResourceId",
@@ -22,7 +23,13 @@
   "reservationEndTime",
   "sshAccountProvisioner",
   "groupSSHAccountProvisionerConfigs",
-  "sshAccountProvisionerAdditionalInfo"
+  "sshAccountProvisionerAdditionalInfo",
+  {
+    name: "reservations",
+    type: ComputeResourceReservation,
+    list: true,
+    default: BaseModel.defaultNewInstance(Array)
+  }
 ];
 
 export default class GroupComputeResourcePreference extends BaseModel {
diff --git a/django_airavata/settings_local.py.ide b/django_airavata/settings_local.py.ide
index 5918819..548e82d 100644
--- a/django_airavata/settings_local.py.ide
+++ b/django_airavata/settings_local.py.ide
@@ -26,7 +26,11 @@
 # EMAIL_HOST_USER = '...'
 # EMAIL_HOST_PASSWORD = '...'
 # EMAIL_USE_TLS = True
-# ADMINS = [('Admin Name', 'admin@example.com')]
+ADMINS = [('Admin Name', 'admin@example.com')]
+# PORTAL_ADMINS receive administrative emails, like when a new user is created
+# This can be set to a different value than ADMINS so that the PORTAL_ADMINS
+# don't receive error emails
+PORTAL_ADMINS = ADMINS
 # SERVER_EMAIL = 'portal@example.com'
 
 # Keycloak Configuration
diff --git a/django_airavata/static/common/js/components/DeleteButton.vue b/django_airavata/static/common/js/components/DeleteButton.vue
index f1a22aa..0e07c43 100644
--- a/django_airavata/static/common/js/components/DeleteButton.vue
+++ b/django_airavata/static/common/js/components/DeleteButton.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="delete-button">
     <b-button variant="danger" @click="$refs.modal.show()" :disabled="disabled">
-      Delete
+      {{ label }}
     </b-button>
     <confirmation-dialog ref="modal" :title="dialogTitle" @ok="$emit('delete')">
       <slot></slot>
@@ -21,6 +21,10 @@
     disabled: {
       type: Boolean,
       default: false
+    },
+    label: {
+      type: String,
+      default: "Delete"
     }
   },
   components: {
diff --git a/django_airavata/static/common/js/components/HumanDate.vue b/django_airavata/static/common/js/components/HumanDate.vue
index 26bd3fa..03f870c 100644
--- a/django_airavata/static/common/js/components/HumanDate.vue
+++ b/django_airavata/static/common/js/components/HumanDate.vue
@@ -1,5 +1,5 @@
 <template>
-  <span :title="date.toString()">{{ fromNow }}</span>
+  <abbr :title="date.toString()">{{ fromNow }}</abbr>
 </template>
 <script>
 import moment from "moment";
diff --git a/django_airavata/static/common/js/layouts/ListLayout.vue b/django_airavata/static/common/js/layouts/ListLayout.vue
index d94628f..dfd77fa 100644
--- a/django_airavata/static/common/js/layouts/ListLayout.vue
+++ b/django_airavata/static/common/js/layouts/ListLayout.vue
@@ -7,6 +7,8 @@
         </slot>
       </div>
       <div class="col-auto">
+        <slot name="additional-buttons">
+        </slot>
         <slot name="new-item-button">
           <b-btn variant="primary" @click="addNewItem" :disabled="newButtonDisabled">
             {{ newItemButtonText }}
diff --git a/django_airavata/static/common/js/utils.js b/django_airavata/static/common/js/utils.js
index 21ff86c..54d43f1 100644
--- a/django_airavata/static/common/js/utils.js
+++ b/django_airavata/static/common/js/utils.js
@@ -1,13 +1,26 @@
-
 export function getProperty(obj, props) {
-    if (typeof props === 'string') {
-        return obj[props];
-    } else if (typeof props === 'object' && props instanceof Array) { // Array
-        return props.reduce((o, prop) => o && prop in o ? o[prop] : undefined, obj);
-    }
+  if (typeof props === "string") {
+    return obj[props];
+  } else if (typeof props === "object" && props instanceof Array) {
+    // Array
+    return props.reduce(
+      (o, prop) => (o && prop in o ? o[prop] : undefined),
+      obj
+    );
+  }
 }
 export function sanitizeHTMLId(id) {
   // Replace anything that isn't an HTML safe id character with underscore
   // Here safe means allowable by HTML5 and also safe to use in a jQuery selector
   return id.replace(/[^a-zA-Z0-9_-]/g, "_");
 }
+export const dateFormatters = {
+  dateTimeInMinutesWithTimeZone: new Intl.DateTimeFormat(undefined, {
+    year: "numeric",
+    month: "2-digit",
+    day: "2-digit",
+    hour: "numeric",
+    minute: "numeric",
+    timeZoneName: "short"
+  })
+};