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"
+ })
+};