diff --git a/allocations/admin.py b/allocations/admin.py index 891a9249..8fc1aa0d 100644 --- a/allocations/admin.py +++ b/allocations/admin.py @@ -1,4 +1,7 @@ +from django import forms from django.contrib import admin +from django.utils.safestring import mark_safe +from django.urls import reverse from util.keycloak_client import KeycloakClient @@ -18,58 +21,291 @@ def project_description(self, obj): def project_title(self, obj): return str(obj.project.title) - def pi_name(self, obj): - return f"{obj.project.pi.first_name} {obj.project.pi.last_name}" + def pi_info(self, obj): + keycloak_client = KeycloakClient() + kc_user = keycloak_client.get_user_by_username(obj.project.pi.username) + if not kc_user: + kc_user = {} + institution = kc_user.get("attributes", {}).get("affiliationInstitution") + country = kc_user.get("attributes", {}).get("country") - def pi_email(self, obj): - return f"{obj.project.pi.email}" + return mark_safe(f""" + + + + + + + + + + + + + +
Name{obj.project.pi.first_name} {obj.project.pi.last_name}
Email + + {obj.project.pi.email} +
Institution{institution}
Country{country}
+ """) - def pi_institution(self, obj): - keycloak_client = KeycloakClient() - uname = obj.project.pi.username - user = keycloak_client.get_user_by_username(uname) - return user.get("attributes", {}).get("affiliationInstitution", "") + def project_info(self, obj): + return mark_safe(f""" + + + + + + + + + + + + +
Charge Code{obj.project.charge_code}
Title{obj.project.title}
Abstract{obj.project.description}
Tag{obj.project.tag.name} - {obj.project.tag.description}
""") + + def allocation_status(self, obj): + if obj.status not in ["pending", "waiting"]: + return f"This allocation is {obj.status}." + + rows = [] + rows.append(f""" + + + + + + + + """) + + styles = """ + + """ + approve_modal = f"""
+
{obj.id}{obj.requestor}{obj.date_requested.date()}{obj.su_requested}{obj.justification} + + + +
+ + + + + + + + + + + + + + + + +
Project
Allocation ID
Requestor
Date Requested{obj.date_requested.date()}
Compute Requested{obj.su_requested}
Compute Allocated
Start Date
End Date
Type + +
Decision Summary + +
+ + + + """ + reject_modal = f"""
+ +
+ """ + contact_modal = """
+ +
+ """ + + return mark_safe(f""" + + + + + + + + + + + + + + {"".join(rows)} + +
IDRequestorDate RequestedStatusSU RequestedJustificationActions
+ {styles} + {approve_modal} + {reject_modal} + {contact_modal} + """) + + def previous_allocations(self, obj): + rows = [] + for alloc in sorted(obj.project.allocations.exclude(status="pending"), reverse=True, key=lambda x: x.date_requested): + rows.append(f""" + {alloc.id} + {alloc.requestor} + {alloc.date_requested.date()} + {alloc.status} + {alloc.start_date.date() if alloc.start_date else ""} + {alloc.expiration_date.date() if alloc.expiration_date else ""} + {alloc.su_used if alloc.su_used else ""} + {alloc.su_allocated if alloc.su_allocated else ""} + {alloc.su_requested} +
Expand{alloc.justification}
+ +
+ Expand + +
+ + """) + return mark_safe(f""" + + + + + + + + + + + + + + + + + + {"".join(rows)} + +
IDRequestorDate RequestedStatusStartEndSU UsageSU AllocatedSU RequestedJustificationDecision summary
+ """) list_display = ( - "project_title", "project", "status", "date_requested", "date_reviewed", "reviewer", ) - fields = ( - "pi_name", - "pi_email", - "pi_institution", - "project", - "project_title", - "project_description", - "justification", - "status", - "requestor", - "decision_summary", - "reviewer", - "date_requested", - "date_reviewed", - "start_date", - "expiration_date", - "su_allocated", + fieldsets = ( + (None, { + "fields": ( + "project_info", + "pi_info", + "allocation_status", + "previous_allocations", + "status", + "start_date", + "expiration_date", + ), + }), + # TODO other allocations ) readonly_fields = [ - "pi_name", - "pi_email", - "pi_institution", - "project", - "project_title", - "project_description", - "justification", - "status", - "requestor", - "decision_summary", - "reviewer", - "date_requested", - "date_reviewed", + "pi_info", + "project_info", + "allocation_status", + "previous_allocations", ] ordering = ["-date_requested"] search_fields = [ @@ -79,6 +315,16 @@ def pi_institution(self, obj): ] list_filter = ["status", "date_requested"] inlines = [ChargeInline] + # form = ReviewAllocationForm + + class Media: + css = { + "all": ("/static/allocations/css/admin.css",), + } + js = ( + '/static/allocations/js/admin.js', + '/static/scripts/cannedresponses.js', + ) admin.site.register(Allocation, AllocationAdmin) diff --git a/allocations/static/allocations/css/admin.css b/allocations/static/allocations/css/admin.css new file mode 100644 index 00000000..fcbbdfa8 --- /dev/null +++ b/allocations/static/allocations/css/admin.css @@ -0,0 +1,16 @@ +.admin-pi .flex-container label { + display: none; +} + +.admin-pi .form-row { + padding-bottom: 0; + display: inline-block; +} + +.admin-pi .form-row .flex-container { + margin-right: 2rem; +} + +fieldset.admin-pi div.form-row div.flex-container div.readonly { + width: 100%; +} diff --git a/allocations/static/allocations/css/main.css b/allocations/static/allocations/css/main.css index 2279e1d7..876f9381 100644 --- a/allocations/static/allocations/css/main.css +++ b/allocations/static/allocations/css/main.css @@ -28,4 +28,4 @@ td.ng-binding{ td p { margin: 0px;} -.allocations-header{border-bottom:2px solid #000}a:hover{cursor:hand;cursor:pointer}.btn,.btn:hover,button:hover{background:none!important;box-shadow:none!important}.btn-danger,.btn-danger:hover{color:#fff!important;background-color:#C53C36!important;border-color:#C53C36!important}.btn-info,.btn-info:hover{color:#fff!important;background-color:#31B0D5!important;border-color:#31B0D5!important}.btn-primary,.btn-primary:hover{color:#fff!important;background-color:#3071A9!important;border-color:#3071A9!important}.btn-success,.btn-success:hover{color:#fff!important;background-color:#59B259!important;border-color:#59B259!important}.btn-warning,.btn-warning:hover{color:#fff!important;background-color:#F89406!important;border-color:#F89406!important}.form-group{margin:10px}.list-group-item{padding:5px}.modal-footer{border-top:0}.modal-header{padding:15px 15px 0;border-bottom:0}.no-border{border:0}.ng-cloak,[ng-cloak],[ng\:cloak]{display:none!important}.row{margin:0}table{margin:0 0!important}.more-text.show-inline{display:inline!important} \ No newline at end of file +.allocations-header{border-bottom:2px solid #000}a:hover{cursor:hand;cursor:pointer}.btn,.btn:hover,button:hover{background:none!important;box-shadow:none!important}.btn-danger,.btn-danger:hover{color:#fff!important;background-color:#C53C36!important;border-color:#C53C36!important}.btn-info,.btn-info:hover{color:#fff!important;background-color:#31B0D5!important;border-color:#31B0D5!important}.btn-primary,.btn-primary:hover{color:#fff!important;background-color:#3071A9!important;border-color:#3071A9!important}.btn-success,.btn-success:hover{color:#fff!important;background-color:#59B259!important;border-color:#59B259!important}.btn-warning,.btn-warning:hover{color:#fff!important;background-color:#F89406!important;border-color:#F89406!important}.form-group{margin:10px}.list-group-item{padding:5px}.modal-footer{border-top:0}.modal-header{padding:15px 15px 0;border-bottom:0}.no-border{border:0}.ng-cloak,[ng-cloak],[ng\:cloak]{display:none!important}.row{margin:0}table{margin:0 0!important}.more-text.show-inline{display:inline!important} diff --git a/allocations/static/allocations/js/admin.js b/allocations/static/allocations/js/admin.js new file mode 100644 index 00000000..08f898ef --- /dev/null +++ b/allocations/static/allocations/js/admin.js @@ -0,0 +1,147 @@ +document.addEventListener('DOMContentLoaded', () => { + // Opening/closing modal code + const modal = document.getElementById("alloc-modal-approve"); + const modalReject = document.getElementById("alloc-modal-reject"); + const modalContact = document.getElementById("alloc-modal-contact"); + const closeButton = document.getElementById("close-btn"); + const approveBtn = document.getElementById("approve-btn"); + const rejectBtn = document.getElementById("reject-btn"); + const contactBtn = document.getElementById("contact-btn"); + const closeBtnReject = document.getElementById("close-btn-reject"); + const closeBtnContact = document.getElementById("close-btn-contact"); + approveBtn.addEventListener("click", function () { + modal.style.display = "block"; + }); + rejectBtn.addEventListener("click", function () { + modalReject.style.display = "block"; + }); + contactBtn.addEventListener("click", function () { + modalContact.style.display = "block"; + }); + closeButton.addEventListener("click", function () { + modal.style.display = "none"; + }); + closeBtnReject.addEventListener("click", function () { + modalReject.style.display = "none"; + }); + closeBtnContact.addEventListener("click", function () { + modalContact.style.display = "none"; + }); + window.onclick = function(event) { + if (event.target == modal) { + modal.style.display = "none"; + } + if (event.target == modalContact) { + modalContact.style.display = "none" + } + if (event.target == modalReject) { + modalReject.style.display = "none"; + } + } + + // Setting defaults on form + const inputStartDate = document.getElementById("alloc-start-date"); + const today = new Date(); + inputStartDate.value = today.toISOString().split('T')[0]; + + const inputEndDate = document.getElementById("alloc-end-date"); + const sixMonthsLater = new Date(); + sixMonthsLater.setMonth((today.getMonth() + 6) % 12); + sixMonthsLater.setFullYear(today.getFullYear() + ((today.getMonth() + 6) >= 12 ? 1 : 0)); + if (sixMonthsLater.getDate() !== today.getDate()) { + sixMonthsLater.setDate(0); // Set to the last day of the previous month + } + inputEndDate.value = sixMonthsLater.toISOString().split('T')[0]; + + // Handling for prefilled responses + const decisionSummary = document.getElementById("idDecisionSummary") + function updateDecisionSummary() { + const selectedType = typeSelect.value; + if (selectedType == 'new') { + decisionSummary.value=allocationNewApproval; + } else if (selectedType == 'renewal') { + decisionSummary.value=allocationRenewalApproval; + } else if (selectedType == 'recharge') { + decisionSummary.value=allocationRechargeApproval; + } else { + decisionSummary.value=""; + } + } + const typeSelect = document.getElementById("allocationApprovalType") + typeSelect.addEventListener("change", updateDecisionSummary); + + function formatErrors(errors) { + return Object.entries(errors) + .map(([field, message]) => `- ${field.charAt(0).toUpperCase() + field.slice(1)}: ${message}`) + .join("\n"); + } + + // Submitting approval + const approveSubmitBtn = document.getElementById("approve-submit-btn"); + approveSubmitBtn.addEventListener("click", function () { + let payload = { + "status": "approved", + "dateReviewed": new Date().toISOString().split("T")[0], + "start": inputStartDate.value, + "end": inputEndDate.value, + "decisionSummary": document.getElementById("idDecisionSummary").value, + "computeAllocated": parseInt(document.getElementById("alloc-compute-allocated").value), + "project": document.getElementById("chargeCode").value, + "requestorId": document.getElementById("requestorId").value, + "id": document.getElementById("allocationId").value, + } + fetch("/admin/allocations/approval/", { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + "X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]').value, + }, + body: JSON.stringify(payload) + }) + .then( res => res.json()) + .then( res => { + if(res.status == "error"){ + console.error(res.errors) + alert(`Error approving allocation:\n${formatErrors(res.errors)}`) + } else { + alert("Approved allocation") + location.reload(); // Refresh admin form + } + }); + }); + + const contactSubmitBtn = document.getElementById("contact-submit-btn") + const inputContactMessage = document.getElementById("idContactMessage") + contactSubmitBtn.addEventListener("click", function(){ + let message = inputContactMessage.value + let payload = { + "allocation": { + "status": document.getElementById("allocationStatus").value, + "id": document.getElementById("allocationId").value, + }, + "rt": { + "requestor": document.getElementById("requestor").value, // requestor username + "problem_description": message, + }, + } + fetch("/admin/allocations/contact/", { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + "X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]').value, + }, + body: JSON.stringify(payload) + }) + .then( res => res.json()) + .then( res => { + if(res.status == "error"){ + alert(`Error contacting PI:\n${formatErrors(res.errors)}`) + } else { + alert(`Successfully contacted PI.`) + location.reload(); // Refresh admin form + } + }); + }) +}); diff --git a/allocations/views.py b/allocations/views.py index cb2f0971..a14e0a81 100644 --- a/allocations/views.py +++ b/allocations/views.py @@ -136,9 +136,6 @@ def approval(request): logger.info("Allocation approval requested by admin: %s", request.user) logger.info("Allocation approval request data: %s", json.dumps(data)) validate_datestring = validators.RegexValidator(r"^\d{4}-\d{2}-\d{2}$") - validate_datetimestring = validators.RegexValidator( - r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$" - ) if not data["decisionSummary"]: errors["decisionSummary"] = "Decision Summary is required." @@ -184,44 +181,6 @@ def approval(request): except ValueError: errors["computeAllocated"] = "Compute Allocated must be a number." - if data["computeRequested"]: - try: - data["computeRequested"] = int(data["computeRequested"]) - except ValueError: - errors["computeRequested"] = "Compute Requested must be a number." - - if data["storageAllocated"]: - try: - data["storageAllocated"] = int(data["storageAllocated"]) - except ValueError: - errors["storageAllocated"] = "Storage Allocated must be a number." - - if data["storageRequested"]: - try: - data["storageRequested"] = int(data["storageRequested"]) - except ValueError: - errors["storageRequested"] = "Storage Requested must be a number." - - if data["memoryAllocated"]: - try: - data["memoryAllocated"] = int(data["memoryAllocated"]) - except ValueError: - errors["memoryAllocated"] = "Memory Allocated must be a number." - - if data["memoryRequested"]: - try: - data["memoryRequested"] = int(data["memoryRequested"]) - except ValueError: - errors["memoryRequested"] = "Memory Requested must be a number." - - if data["projectId"]: - try: - data["projectId"] = int(data["projectId"]) - except ValueError: - errors["projectId"] = "Project id must be number." - else: - errors["projectId"] = "Project id is required." - if not data["project"]: errors["project"] = "Project charge code is required." @@ -233,19 +192,6 @@ def approval(request): else: errors["reviewerId"] = "Reviewer id is required." - if not data["reviewer"]: - errors["reviewer"] = "Reviewer username is required." - - if data["dateRequested"]: - try: - validate_datetimestring(data["dateRequested"]) - except ValidationError: - errors["dateRequested"] = ( - 'Requested date must be a valid date string e.g. "2015-05-20T05:00:00Z" .' - ) - # else: - # errors['dateRequested'] = 'Requested date is required.' - if data["dateReviewed"]: try: validate_datestring(data["dateReviewed"])