From 97368b1964d7a5745fa2443f79b0dce026500d62 Mon Sep 17 00:00:00 2001 From: aschmidt34 <124093649+aschmidt34@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:44:05 -0500 Subject: [PATCH 1/3] 26.3 fb ios app integration (#1002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR DETAILS: - anesthesiaRecoveriesFullHistory.sql: Defines sql for table that holds data for full history. - anesthesiaRecovery.query.xml: Defines row details for anesthesiaRecovery official table. - anesthesiaRecovery/.qview.xml: Defines anesthesiaRecovery official table default view. - anesthesiaRecovery/Full History.qview.xml: Defines anesthesiaRecovery full history table default view. - anesthesiaRecovery/.qview.xml: Defines anesthesiaRecovery official table summary view. - anesthesiaRecovery/.qview.xml: Defines anesthesiaRecovery official table full history view. - anesthesiaRecovery/Full History.qview.xml: Adds anesthesia recovery as an option for full history. - session_log.query.xml: Adds a customizer for the session log query. - WNPRC_EHRController.java: Created trigger to validate & upload data to anesthesiaRecovery dataset. - WNPRC_EHRModule: Registers email notification. - AnesthesiaRecoveryReviewNotification.java: Creates email notification to let users know if there are unfinished recoveries or rows marked as 'review required'. - NotificationToolkit.java: Updated functionality to allow multiple params for field clauses. - WNPRC_EHRCustomizer.java: Added customizers for anesthesiaRecovery and sessionLog tables. - .gitignore: Added .DS_Store so iOS only uploads necessary files and no caches. - README: Empty, but created for future use. - build.gradle: Needed to create new external iOS module. - module.properties: Needed to create new external iOS module. - wnprc_ios_app-0.000-25.000.sql: Upgrade script that inserts iOS app tables into app on first load. - wnprc_ios_app.xml: Defines default .xml view for all iOS app tables. - begin.html: Created a default landing page for new iOS module. - wnprc_ios_appContainerListener.java: Needed to create new external iOS module. - wnprc_ios_appController.java: Creates all validation functions for new iOS module tables. They are muted for now and can be unmuted when needed. - wnprc_ios_appManager.java: Needed to create new external iOS module. - wnprc_ios_appModule.java: Needed to create new external iOS module. - wnprc_ios_appSchema.java: Needed to create new external iOS module. ## Rationale ## Related Pull Requests - ## Changes - --- .../study/anesthesiaRecoveriesFullHistory.sql | 12 + .../study/anesthesiaRecovery.query.xml | 75 ++++ .../study/anesthesiaRecovery/.qview.xml | 37 ++ .../anesthesiaRecovery/Full History.qview.xml | 28 ++ .../anesthesiaRecovery/Summary.qview.xml | 12 + .../queries/study/wnprcFullHistory.sql | 21 +- .../queries/study/wnprcFullHistory/.qview.xml | 2 +- .../wnprc_ios_app/session_log.query.xml | 10 + .../labkey/wnprc_ehr/WNPRC_EHRController.java | 339 ++++++++++++++++++ .../org/labkey/wnprc_ehr/WNPRC_EHRModule.java | 1 + .../AnesthesiaRecoveryReviewNotification.java | 204 +++++++++++ .../notification/NotificationToolkit.java | 26 +- .../wnprc_ehr/table/WNPRC_EHRCustomizer.java | 197 +++++++++- wnprc_ios_app/README.md | 1 + wnprc_ios_app/build.gradle | 21 ++ wnprc_ios_app/module.properties | 22 ++ .../postgresql/wnprc_ios_app-0.000-26.000.sql | 129 +++++++ .../resources/schemas/wnprc_ios_app.xml | 83 +++++ wnprc_ios_app/resources/views/begin.html | 1 + .../wnprc_ios_appContainerListener.java | 55 +++ .../wnprc_ios_appController.java | 272 ++++++++++++++ .../wnprc_ios_app/wnprc_ios_appManager.java | 32 ++ .../wnprc_ios_app/wnprc_ios_appModule.java | 112 ++++++ .../wnprc_ios_app/wnprc_ios_appSchema.java | 74 ++++ 24 files changed, 1753 insertions(+), 13 deletions(-) create mode 100644 WNPRC_EHR/resources/queries/study/anesthesiaRecoveriesFullHistory.sql create mode 100644 WNPRC_EHR/resources/queries/study/anesthesiaRecovery.query.xml create mode 100644 WNPRC_EHR/resources/queries/study/anesthesiaRecovery/.qview.xml create mode 100644 WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Full History.qview.xml create mode 100644 WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Summary.qview.xml create mode 100644 WNPRC_EHR/resources/queries/wnprc_ios_app/session_log.query.xml create mode 100644 WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/AnesthesiaRecoveryReviewNotification.java create mode 100644 wnprc_ios_app/README.md create mode 100644 wnprc_ios_app/build.gradle create mode 100644 wnprc_ios_app/module.properties create mode 100644 wnprc_ios_app/resources/schemas/dbscripts/postgresql/wnprc_ios_app-0.000-26.000.sql create mode 100644 wnprc_ios_app/resources/schemas/wnprc_ios_app.xml create mode 100644 wnprc_ios_app/resources/views/begin.html create mode 100644 wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appContainerListener.java create mode 100644 wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appController.java create mode 100644 wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appManager.java create mode 100644 wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java create mode 100644 wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appSchema.java diff --git a/WNPRC_EHR/resources/queries/study/anesthesiaRecoveriesFullHistory.sql b/WNPRC_EHR/resources/queries/study/anesthesiaRecoveriesFullHistory.sql new file mode 100644 index 000000000..8de749e9f --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/anesthesiaRecoveriesFullHistory.sql @@ -0,0 +1,12 @@ +SELECT + Id, + date, + recoveryStartTime, + submitterInitials, + qcstate, + taskid, + observation, + recoverySpeed, + recoveryCondition, + totalRecoveryTime +FROM study.anesthesiaRecovery \ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/study/anesthesiaRecovery.query.xml b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery.query.xml new file mode 100644 index 000000000..3b2684fbd --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery.query.xml @@ -0,0 +1,75 @@ + + + + + + + true + false + + + + true + false + + + + Recovery Start Time Final + + + + Task Id + + ehr + tasks + taskid + + /ehr/WNPRC/EHR/taskDetails.view?formtype=Anesthesia%20Recovery&taskid=${taskid} + true + false + + + + true + false + + + + true + false + + + + true + false + + + + Recovery Reason Final + + + + Group ID Final + + + + Cage Final + + + + Location Final + + + + Room Final + + + + Status + + + +
+
+
+
\ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/.qview.xml b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/.qview.xml new file mode 100644 index 000000000..971cfb1b3 --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/.qview.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Full History.qview.xml b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Full History.qview.xml new file mode 100644 index 000000000..5552f73c2 --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Full History.qview.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Summary.qview.xml b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Summary.qview.xml new file mode 100644 index 000000000..7d6d023e9 --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/anesthesiaRecovery/Summary.qview.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/WNPRC_EHR/resources/queries/study/wnprcFullHistory.sql b/WNPRC_EHR/resources/queries/study/wnprcFullHistory.sql index 63c22ab93..0961bfb0b 100644 --- a/WNPRC_EHR/resources/queries/study/wnprcFullHistory.sql +++ b/WNPRC_EHR/resources/queries/study/wnprcFullHistory.sql @@ -38,4 +38,23 @@ SELECT null AS requestid FROM study.waterTotalByDate -WHERE TotalWater IS NOT NULL \ No newline at end of file +WHERE TotalWater IS NOT NULL + +UNION ALL +SELECT + Id AS Id, + date, + null AS project, + 'Anesthesia Recovery' AS dataset, + 'anesthesiarecovery' AS DataSetName, + 'Animal fully recovered.' AS remark, + 'Total Recovery Time: ' || CAST(CEILING(totalRecoveryTime) AS VARCHAR) || ' minutes' || CHR(10) || + 'Recovery Speed: ' || CAST(recoverySpeed AS VARCHAR) || CHR(10) || + 'Recovery Condition: ' || CAST(recoveryCondition AS VARCHAR) + AS description, + submitterInitials AS performedBy, + qcstate AS qcstate, + taskid AS taskid, + null AS requestid +FROM study.anesthesiaRecoveriesFullHistory +WHERE observation = 'Fully Recovered' \ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/study/wnprcFullHistory/.qview.xml b/WNPRC_EHR/resources/queries/study/wnprcFullHistory/.qview.xml index 4c5f0b659..6e80b1c2f 100644 --- a/WNPRC_EHR/resources/queries/study/wnprcFullHistory/.qview.xml +++ b/WNPRC_EHR/resources/queries/study/wnprcFullHistory/.qview.xml @@ -16,7 +16,7 @@ - + \ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/wnprc_ios_app/session_log.query.xml b/WNPRC_EHR/resources/queries/wnprc_ios_app/session_log.query.xml new file mode 100644 index 000000000..6262ff6c1 --- /dev/null +++ b/WNPRC_EHR/resources/queries/wnprc_ios_app/session_log.query.xml @@ -0,0 +1,10 @@ + + + + + + +
+
+
+
\ No newline at end of file diff --git a/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRController.java b/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRController.java index c6f6e98f2..0c215b13e 100644 --- a/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRController.java +++ b/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRController.java @@ -18,13 +18,16 @@ import au.com.bytecode.opencsv.CSVWriter; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.text.WordUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.util.ArrayUtil; import org.jetbrains.annotations.Nullable; import org.joda.time.DateTime; import org.joda.time.LocalDate; +import org.joda.time.LocalDateTime; import org.json.JSONArray; import org.json.JSONObject; import org.jsoup.Jsoup; @@ -51,12 +54,16 @@ import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; import org.labkey.api.ehr.EHRDemographicsService; +import org.labkey.api.ehr.EHRQCState; import org.labkey.api.ehr.EHRService; import org.labkey.api.ehr.demographics.AnimalRecord; import org.labkey.api.exp.property.Domain; +import org.labkey.api.formSchema.Field; +import org.labkey.api.ldk.notification.Notification; import org.labkey.api.module.Module; import org.labkey.api.module.ModuleLoader; import org.labkey.api.module.ModuleProperty; +import org.labkey.api.qc.QCStateManager; import org.labkey.api.query.BatchValidationException; import org.labkey.api.query.FieldKey; import org.labkey.api.query.QueryHelper; @@ -64,6 +71,7 @@ import org.labkey.api.query.QueryUpdateService; import org.labkey.api.query.QueryUpdateServiceException; import org.labkey.api.query.UserSchema; +import org.labkey.api.query.ValidationException; import org.labkey.api.reader.ExcelFactory; import org.labkey.api.resource.DirectoryResource; import org.labkey.api.resource.FileResource; @@ -116,6 +124,7 @@ import org.labkey.wnprc_ehr.dataentry.validators.exception.InvalidAnimalIdException; import org.labkey.wnprc_ehr.dataentry.validators.exception.InvalidProjectException; import org.labkey.wnprc_ehr.notification.NecropsyEditRequestNotification; +import org.labkey.wnprc_ehr.notification.NotificationToolkit; import org.labkey.wnprc_ehr.schemas.WNPRC_Schema; import org.labkey.wnprc_ehr.service.dataentry.BehaviorDataEntryService; import org.springframework.validation.BindException; @@ -130,7 +139,9 @@ import java.sql.Timestamp; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; @@ -141,6 +152,10 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; + +import org.labkey.api.action.SimpleApiJsonForm; +import org.springframework.validation.Errors; import static java.time.temporal.ChronoUnit.DAYS; @@ -2452,4 +2467,328 @@ public Object execute(NecropsyEditRequestNotificationForm form, BindException er } } + @RequiresLogin + public static class UpdateAnesthesiaRecoveryDatasetAction extends MutatingApiAction { + + @Override + public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception { + _log.info("UPDATE CALLED: UpdateAnesthesiaRecoveryDatasetAction()"); + + // 1. Sets up environment. + // 1a. Sets variables. + NotificationToolkit notificationToolkit = new NotificationToolkit(); + JSONObject response = new JSONObject(); + response.put("success",false); + response.put("detailedResponse", ""); + response.put("rowsUpdated", 0); + // 1b. Gets QCState name. + var qcStateStarted = EHRService.QCSTATES.Scheduled.getQCState(getContainer()).getRowId(); + // TODO: Ask labkey why 'started' state isn't working. + // - Retrieved 'started' rowid with code below and it works. + // - Then I call this later: taskRecord.put("qcstate", qcStateStarted); + // - Which throws this error: Insufficient permissions to update: tasks to status: undefined, from: Scheduled + // - Why is this so much hassle trying to use the 'started' qc state and why can't I find any usage anywhere. +// String[] qcStateColumns = new String[]{"rowid"}; +// SimpleFilter qcStateFilter = new SimpleFilter("label", "Started", CompareType.EQUAL); +// ArrayList> startedQcStateResult = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "ehr", "status", qcStateFilter, null, qcStateColumns); +// if (startedQcStateResult != null || startedQcStateResult.isEmpty()) { +// qcStateStarted = Integer.parseInt(startedQcStateResult.get(0).get("rowid")); +// } + + + // 2. Gets passed-in data. + // 2a. Verifies an object with data was passed in. + JSONObject inputJson = form.getJsonObject(); + if (inputJson == null) { + response.put("detailedResponse", "No JSON payload provided."); + return response; + } + // 2b. Verifies passed-in object has rows. + if (!inputJson.has("rows") || inputJson.getJSONArray("rows").isEmpty()) { + response.put("detailedResponse", "No rows provided for the update."); + return response; + } + // 2c. Converts the JSONArray to the row map our loop expects. + List> rowsToValidate = new ArrayList<>(); + JSONArray rowsArray = inputJson.getJSONArray("rows"); + for (int i = 0; i < rowsArray.length(); i++) { + rowsToValidate.add(rowsArray.getJSONObject(i).toMap()); + } + + + // 3. Creates the object that collects errors from all failing rows, or the successful rows and tasks to upload. + BatchValidationException batchErrors = new BatchValidationException(); + List> rowsToInsert = new ArrayList<>(); + List> rowsToDelete = new ArrayList<>(); + List> tasksToInsert = new ArrayList<>(); + List> tasksToUpdate = new ArrayList<>(); + + + // 4. Loops through each row and validates them. + for (int i = 0; i < rowsToValidate.size(); i++) { + // Retrieves the current row. + Map row = rowsToValidate.get(i); + // Retrieves required values. + String id = row.get("Id") != null ? row.get("Id").toString() : null; + String observer = row.get("observer") != null ? row.get("observer").toString() : null; + String recoveryId = row.get("recoveryId") != null ? row.get("recoveryId").toString() : null; + String observation = row.get("observation") != null ? row.get("observation").toString() : null; + String submitterInitials = row.get("submitterInitials") != null ? row.get("submitterInitials").toString() : null; + java.time.LocalDateTime serverDate = java.time.LocalDateTime.now(); // Explicitly import Java here, otherwise script defaults to joda time due to both being imported above. + // TODO: Ask users if initials should be required for deleting a row. + if (submitterInitials == null || submitterInitials == "") { + submitterInitials = "empty"; + } + // Retrieves optional values (for all observations). + String observerComments = row.get("observerComments") != null ? row.get("observerComments").toString() : null; + String assignedTo = row.get("assignedTo") != null ? row.get("assignedTo").toString() : null; + String recoverySpeed = row.get("recoverySpeed") != null ? row.get("recoverySpeed").toString() : null; + String recoveryCondition = row.get("recoveryCondition") != null ? row.get("recoveryCondition").toString() : null; + String finalizeComments = row.get("finalizeComments") != null ? row.get("finalizeComments").toString() : null; + String cageLockSecure = row.get("cageLockSecure") != null ? row.get("cageLockSecure").toString() : null; + String deviceId = row.get("deviceId") != null ? row.get("deviceId").toString() : null; + // Retrieves optional values (for fields that should only be updated on 'Imported' observations - customizer sets all other observation rows to reference 'Imported' row for these values). + String recoveryReason = null; + String groupId = null; + String cage = null; + String location = null; + String room = null; + if (observation.equals("Imported")) { + recoveryReason = row.get("recoveryReason") != null ? row.get("recoveryReason").toString() : "none"; + groupId = row.get("groupId") != null ? row.get("groupId").toString() : null; + cage = row.get("cage") != null ? row.get("cage").toString() : null; + location = row.get("location") != null ? row.get("location").toString() : null; + room = row.get("room") != null ? row.get("room").toString() : null; + } + // Gets passed-in review required status. + String reviewRequired = row.get("reviewRequired") != null ? row.get("reviewRequired").toString() : null; + Boolean reviewRequiredParsed = reviewRequired != null ? Boolean.parseBoolean(reviewRequired) : false; + Integer rowQcState = reviewRequiredParsed == true ? EHRService.QCSTATES.ReviewRequired.getQCState(getContainer()).getRowId() : EHRService.QCSTATES.Completed.getQCState(getContainer()).getRowId(); + // Sets data to new row variable. + Map validatedRow = new HashMap<>(); + validatedRow.put("Id", id); + validatedRow.put("date", Timestamp.valueOf(serverDate)); + validatedRow.put("recoveryReason", recoveryReason); + validatedRow.put("observer", observer); + validatedRow.put("recoveryId", recoveryId); + validatedRow.put("observation", observation); + validatedRow.put("submitterInitials", submitterInitials); + validatedRow.put("observerComments", observerComments); + validatedRow.put("room", room); + validatedRow.put("assignedTo", assignedTo); + validatedRow.put("recoverySpeed", recoverySpeed); + validatedRow.put("recoveryCondition", recoveryCondition); + validatedRow.put("groupId", groupId); + validatedRow.put("finalizeComments", finalizeComments); + validatedRow.put("location", location); + validatedRow.put("cage", cage); + validatedRow.put("cageLockSecure", cageLockSecure); + validatedRow.put("deviceId", deviceId); + validatedRow.put("QCState", rowQcState); + + // 4a. Check row for required fields. + String[] requiredFields = { + "Id", "observer", "recoveryId", "observation", "submitterInitials" + }; + boolean missingField = false; + for (String field : requiredFields) { + if (validatedRow.get(field) == null || validatedRow.get(field).toString().trim().isEmpty()) { + batchErrors.addRowError(new ValidationException("Row " + (i + 1) + " is missing required field: " + field)); + missingField = true; + break; // Stop checking this row, move to next. + } + } + if (missingField) { + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + + // 4b. Validate existing table data before updating (depending on observation being added). + if (observation.equals("Imported")) { + // Verify no other active recoveries exist for the current animal. + String[] existingRecoveriesTargetColumn = new String[]{"recoveryId"}; + SimpleFilter recoveriesStartedFilter = new SimpleFilter("id", id, CompareType.EQUAL); + recoveriesStartedFilter.addCondition("observation", "Imported", CompareType.EQUAL); + SimpleFilter recoveriesFinishedFilter = new SimpleFilter("id", id, CompareType.EQUAL); + recoveriesFinishedFilter.addCondition("observation", "Fully Recovered;Deleted", CompareType.IN); + ArrayList> recoveriesStartedRows = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "study", "anesthesiaRecovery", recoveriesStartedFilter, null, existingRecoveriesTargetColumn); + ArrayList> recoveriesFinishedRows = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "study", "anesthesiaRecovery", recoveriesFinishedFilter, null, existingRecoveriesTargetColumn); + // Verify counts match. + if (recoveriesStartedRows != null && !recoveriesStartedRows.isEmpty() && recoveriesFinishedRows != null && !recoveriesFinishedRows.isEmpty() && recoveriesStartedRows.size() != recoveriesFinishedRows.size()) { + batchErrors.addRowError((new ValidationException("Animal: " + id + " already has an active recovery in-progress. Please finish this recovery before beginning a new one."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + else if (recoveriesStartedRows != null && !recoveriesStartedRows.isEmpty() && recoveriesFinishedRows == null) { + batchErrors.addRowError((new ValidationException("Animal: " + id + " already has an active recovery in-progress. Please finish this recovery before beginning a new one."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + // Verifies animal is currently alive and exists at center. + SimpleFilter existsAliveAtCenterFilter = new SimpleFilter("id", id, CompareType.EQUAL); + String[] existsAliveAtCenterTargetColumns = new String[]{"Id", "calculated_status"}; + ArrayList> existsAliveAtCenterRows = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "study", "demographics", existsAliveAtCenterFilter, null, existsAliveAtCenterTargetColumns); + if (existsAliveAtCenterRows == null || existsAliveAtCenterRows.isEmpty()) { + batchErrors.addRowError((new ValidationException("Animal does not currently exist at the center."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + else if (!existsAliveAtCenterRows.get(0).get("calculated_status").equals("Alive")) { + batchErrors.addRowError((new ValidationException("Animal is no longer alive."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + } + else if (observation.equals("Deleted")) { + // Verify only 1 row exists with this 'recoveryId' and has the status of 'Imported', then retrieve the lsid. + String[] existingRecoveryLsidColumn = new String[]{"lsid"}; + SimpleFilter existingRecoveryFilter = new SimpleFilter("recoveryId", recoveryId, CompareType.EQUAL); + ArrayList> existingRecoveryRows = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "study", "anesthesiaRecovery", existingRecoveryFilter, null, existingRecoveryLsidColumn); + if (existingRecoveryRows == null || existingRecoveryRows.isEmpty()) { + batchErrors.addRowError((new ValidationException("Recovery: " + recoveryId + " has no data to delete."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + if (existingRecoveryRows.size() > 1) { + batchErrors.addRowError((new ValidationException("Recovery: " + recoveryId + " has already had 1 or more observations after 'Imported'. In-progress recoveries cannot be deleted."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + else { + validatedRow.put("lsid", existingRecoveryRows.get(0).get("lsid")); + } + } + else if (observation.equals("Unfinalized")) { + // Verify this animal has no other open recoveries currently active. + String[] existingRecoveriesTargetColumn = new String[]{"recoveryId"}; + SimpleFilter recoveriesStartedFilter = new SimpleFilter("id", id, CompareType.EQUAL); + recoveriesStartedFilter.addCondition("observation", "Imported", CompareType.EQUAL); + SimpleFilter recoveriesFinishedFilter = new SimpleFilter("id", id, CompareType.EQUAL); + recoveriesFinishedFilter.addCondition("observation", "Fully Recovered;Deleted", CompareType.IN); + ArrayList> recoveriesStartedRows = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "study", "anesthesiaRecovery", recoveriesStartedFilter, null, existingRecoveriesTargetColumn); + ArrayList> recoveriesFinishedRows = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "study", "anesthesiaRecovery", recoveriesFinishedFilter, null, existingRecoveriesTargetColumn); + // Verify counts match. + if (recoveriesStartedRows != null && !recoveriesStartedRows.isEmpty() && recoveriesFinishedRows != null && !recoveriesFinishedRows.isEmpty() && recoveriesStartedRows.size() != recoveriesFinishedRows.size()) { + batchErrors.addRowError((new ValidationException("Animal: " + id + " already has an active recovery in-progress. Please finish this recovery before restoring a previous one."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + else if (recoveriesStartedRows != null && !recoveriesStartedRows.isEmpty() && recoveriesFinishedRows == null) { + batchErrors.addRowError((new ValidationException("Animal: " + id + " already has an active recovery in-progress. Please finish this recovery before restoring a previous one."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + + // Verify a row exists with this 'recoveryId' and a status of 'Fully Recovered', then retrieve the lsid. + String[] existingRecoveryLsidColumn = new String[]{"lsid"}; + SimpleFilter existingFinalizedRecoveryFilter = new SimpleFilter("recoveryId", recoveryId, CompareType.EQUAL); + existingFinalizedRecoveryFilter.addCondition("observation", "Fully Recovered", CompareType.EQUAL); + ArrayList> existingRecoveryRows = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(getContainer(), getUser(), "study", "anesthesiaRecovery", existingFinalizedRecoveryFilter, null, existingRecoveryLsidColumn); + if (existingRecoveryRows == null || existingRecoveryRows.isEmpty()) { + batchErrors.addRowError((new ValidationException("Recovery: " + recoveryId + " has no recovery data to resume."))); + continue; // Invalid row; skip adding to batch update and continue checking other rows. + } + else { + validatedRow.put("lsid", existingRecoveryRows.get(0).get("lsid")); + } + } + + // 4c. Create or Update task into task dataset. + Map taskRecord = new HashMap<>(); + taskRecord.put("taskid", recoveryId); + taskRecord.put("title", "Anesthesia Recovery"); + taskRecord.put("category", "task"); + taskRecord.put("formType", "Anesthesia Recovery"); + taskRecord.put("assignedTo", getUser().getUserId()); + if (observation.equals("Imported")) { + taskRecord.put("qcstate", EHRService.QCSTATES.Scheduled.getQCState(getContainer()).getRowId()); + tasksToInsert.add(taskRecord); + } + else if (observation.equals("Sitting Upright") || observation.equals("Laying Down")) { +// taskRecord.put("qcstate", qcStateStarted); + taskRecord.put("qcstate", EHRService.QCSTATES.Scheduled.getQCState(getContainer()).getRowId()); + tasksToUpdate.add(taskRecord); + } + else if (observation.equals("Fully Recovered")) { + taskRecord.put("qcstate", EHRService.QCSTATES.Completed.getQCState(getContainer()).getRowId()); + tasksToUpdate.add(taskRecord); + } + else if (observation.equals("Deleted")) { + taskRecord.put("qcstate", EHRService.QCSTATES.DeleteRequested.getQCState(getContainer()).getRowId()); + tasksToUpdate.add(taskRecord); + } + else if (observation.equals("Unfinalized")) { + taskRecord.put("qcstate", EHRService.QCSTATES.Scheduled.getQCState(getContainer()).getRowId()); + tasksToUpdate.add(taskRecord); + } + + // 4e. Current row is valid, added to rowsToInsert for the batch update. + if (observation.equals("Unfinalized") || observation.equals("Deleted")) { + rowsToDelete.add(validatedRow); + } else { + rowsToInsert.add(validatedRow); + } + } + + // 6. Report all manual validation errors back to the user. + if (batchErrors.hasErrors()) { + String combinedErrors = batchErrors.getRowErrors().stream() + .map(error -> "Row " + error.getRowNumber() + ": " + error.getMessage()) + .collect(Collectors.joining("\n")); + response.put("success", false); + response.put("detailedResponse", combinedErrors); + response.put("errors", batchErrors.getRowErrors()); + return response; + } + + // 7. Officially updates the database if all manual validation has been passed. + try (DbScope.Transaction transaction = StudySchema.getInstance().getSchema().getScope().ensureTransaction()) { + // 7a. Creates the environment variables. + TableInfo anesthesiaTableInfo = QueryService.get().getUserSchema(getUser(), getContainer(), "study").getTable("anesthesiaRecovery"); + TableInfo tasksTableInfo = QueryService.get().getUserSchema(getUser(), getContainer(), "ehr").getTable("tasks"); + QueryUpdateService anesthesiaTableService = anesthesiaTableInfo.getUpdateService(); + QueryUpdateService tasksTableService = tasksTableInfo.getUpdateService(); + BatchValidationException dbErrors = new BatchValidationException(); + rowsToInsert = SimpleQueryUpdater.makeRowListCaseInsensitive(rowsToInsert); + rowsToDelete = SimpleQueryUpdater.makeRowListCaseInsensitive(rowsToDelete); + tasksToInsert = SimpleQueryUpdater.makeRowListCaseInsensitive(tasksToInsert); + tasksToUpdate = SimpleQueryUpdater.makeRowListCaseInsensitive(tasksToUpdate); + + // 7b. Creates the tasks containing all rows to be inserted. + List> anesthesiaRowsToInsert = anesthesiaTableService.insertRows(getUser(), getContainer(), rowsToInsert, dbErrors, null, null); + // TODO: Check with labkey to verify there's no dbErrors to add to the anesthesiaRowsToDelete object below. Row deletion is not done in batch so this will work regardless. + List> anesthesiaRowsToDelete = anesthesiaTableService.deleteRows(getUser(), getContainer(), rowsToDelete, null, null); + tasksTableService.insertRows(getUser(), getContainer(), tasksToInsert, dbErrors, null, null); + tasksTableService.updateRows(getUser(), getContainer(), tasksToUpdate, tasksToUpdate, dbErrors, null, null); + + // 7c. Checks for errors in any row and aborts the insert. + if (dbErrors.hasErrors()) { + throw dbErrors; + } + + // 7d. Executes the Task to insert all rows via a batch transaction. + transaction.commit(); + response.put("success", true); + response.put("detailedResponse", "Database save successful."); + response.put("rowsInserted", anesthesiaRowsToInsert.size()); + response.put("rowsUpdated", anesthesiaRowsToDelete.size()); + return response; + + } + // Catches any errors. + catch (Exception e) { + _log.info("There was an issue modifying the anesthesiaRecovery dataset: " + e.getMessage()); + response.put("success", false); + response.put("detailedResponse", "Database save failed: " + e.getMessage()); + return response; + } + } + } + + public static class AnesthesiaBatchForm { + private List> rows; + + public List> getRows() { + return rows; + } + + public void setRows(List> rows) { + this.rows = rows; + } + } + + + + } diff --git a/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRModule.java b/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRModule.java index a0b9b496f..8717c7b8e 100644 --- a/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRModule.java +++ b/WNPRC_EHR/src/org/labkey/wnprc_ehr/WNPRC_EHRModule.java @@ -394,6 +394,7 @@ public void registerNotifications() { new ClinpathResultAlertsRevamp(this), new LargeInfantAlertsRevamp(this), new OverdueWeightAlertsRevamp(this), + new AnesthesiaRecoveryReviewNotification(this), new SiteErrorAlertsRevamp(this), new WeightAlertsRevamp(this) ); diff --git a/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/AnesthesiaRecoveryReviewNotification.java b/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/AnesthesiaRecoveryReviewNotification.java new file mode 100644 index 000000000..3e39d0475 --- /dev/null +++ b/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/AnesthesiaRecoveryReviewNotification.java @@ -0,0 +1,204 @@ +package org.labkey.wnprc_ehr.notification; + +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.module.Module; +import org.labkey.api.security.User; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public class AnesthesiaRecoveryReviewNotification extends AbstractEHRNotification { + // Class Variables + NotificationToolkit notificationToolkit = new NotificationToolkit(); + NotificationToolkit.DateToolkit dateToolkit = new NotificationToolkit.DateToolkit(); + NotificationToolkit.StyleToolkit styleToolkit = new NotificationToolkit.StyleToolkit(); + + + + + + // Constructors + + /** + * This constructor is used to register the notification in WNPRC_EHRModule.java. + * + * @param owner + */ + public AnesthesiaRecoveryReviewNotification(Module owner) { super(owner); } + + + + + + // Notification Details + @Override + public String getName() { return "Anesthesia Recovery Review"; } + + @Override + public String getDescription() { + return "This report is designed to identify any issues with the Anesthesia Recoveries dataset."; + } + @Override + public String getEmailSubject(Container c) { + return "Anesthesia Recovery Review: " + dateToolkit.getCurrentTime(); + } + @Override + public String getScheduleDescription() { return "Daily at 1:00PM and 3:00PM"; } + @Override + public String getCronString() { return notificationToolkit.createCronString("0", "13,15", "*"); } + @Override + public String getCategory() { return "iOS App Notifications"; } + + + + + + // Message Creation + public String getMessageBodyHTML(Container c, User u) { + // Creates variables & gets data. + final StringBuilder messageBody = new StringBuilder(); + AnesthesiaRecoveryReviewNotificationObject myRecoveriesObject = new AnesthesiaRecoveryReviewNotificationObject(c, u); + AnesthesiaRecoveryReviewReviewRequiredObject myRequiredReviewsObject = new AnesthesiaRecoveryReviewReviewRequiredObject(c, u); + + // Creates CSS. + messageBody.append(styleToolkit.beginStyle()); + messageBody.append(styleToolkit.setBasicTableStyle()); + messageBody.append(styleToolkit.setHeaderRowBackgroundColor("#d9d9d9")); + messageBody.append(styleToolkit.endStyle()); + + // Begins message info. + messageBody.append("

This email contains any issues with the Anesthesia Recovery dataset. It was run on: " + dateToolkit.getCurrentTime() + "

"); + + // Creates table. + if (myRecoveriesObject.unclosedRecoveries.isEmpty() && myRequiredReviewsObject.reviewRequiredRecoveries.isEmpty()) { +// messageBody.append("All anesthesia recoveries have been closed and no reviews are needed."); // TODO: Use this if users want emails to still send when all recoveries are closed. + notificationToolkit.sendEmptyNotificationRevamp(c, u, "Anesthesia Recovery Review"); + return null; + } + else { + if (!myRecoveriesObject.unclosedRecoveries.isEmpty()) { + messageBody.append("The following recoveries are still open and have not been closed yet:"); + for (HashMap result : myRecoveriesObject.unclosedRecoveries) { + messageBody.append(result.get("Id") + "
"); + } + messageBody.append(notificationToolkit.createHyperlink("Click here to view all unclosed recoveries


", myRecoveriesObject.unclosedRecoveriesURL)); + } + if (!myRequiredReviewsObject.reviewRequiredRecoveries.isEmpty()) + { + messageBody.append("The following recoveries have been flagged as 'Review Required':"); + for (HashMap result : myRequiredReviewsObject.reviewRequiredRecoveries) + { + messageBody.append(result.get("Id") + "
"); + } + messageBody.append(notificationToolkit.createHyperlink("Click here to view all 'Review Required' recoveries


", myRequiredReviewsObject.reviewRequiredRecoveriesURL)); + } + } + + // Returns message. + return messageBody.toString(); + } + + + public static class AnesthesiaRecoveryReviewNotificationObject { + Container c; + User u; + NotificationToolkit notificationToolkit = new NotificationToolkit(); + NotificationToolkit.DateToolkit dateToolkit = new NotificationToolkit.DateToolkit(); + + // Constructor function. + public AnesthesiaRecoveryReviewNotificationObject(Container currentContainer, User currentUser) { + this.c = currentContainer; + this.u = currentUser; + this.getUnclosedAnesthesiaRecoveries(); + } + + // Find all anesthesia recoveries that have been opened, but not closed. + ArrayList> unclosedRecoveries; + String unclosedRecoveriesURL; + private void getUnclosedAnesthesiaRecoveries() { + // Creates filter. + SimpleFilter openedFilter = new SimpleFilter("observation", "Imported", CompareType.EQUAL); + SimpleFilter closedFilter = new SimpleFilter("observation", "Fully Recovered", CompareType.EQUAL); + // Creates sort. + Sort mySort = new Sort("Id"); + // Creates columns to retrieve. + String[] targetColumns = new String[]{"Id", "recoveryId"}; // TODO: Change this to task after implementing TaskID (only needed if we remove recoveryId). + // Runs query. + ArrayList> openedArray = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(c, u, "study", "anesthesiaRecovery", openedFilter, mySort, targetColumns); + ArrayList> closedArray = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(c, u, "study", "anesthesiaRecovery", closedFilter, mySort, targetColumns); + + // 1. Extract recoveryIds from closedArray into a Set. + Set closedIds = closedArray.stream() + .map(map -> map.get("recoveryId")) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + // 2. Filter openedArray to find items NOT in the closedIds set. + List> unclosedArray = openedArray.stream() + .filter(map -> !closedIds.contains(map.get("recoveryId"))) + .toList(); + // 3. Creates a URL consisting of all unclosed ID's. CompareType.IN requries a semicolon separated string list. + List unclosedRecoveryIds = unclosedArray.stream() + .map(map -> map.get("recoveryId")) + .filter(Objects::nonNull) + .toList(); + String unclosedRecoverIdsAsString = String.join(";", unclosedRecoveryIds); + SimpleFilter unclosedFilter = new SimpleFilter("recoveryId", unclosedRecoverIdsAsString, CompareType.IN); + String viewQueryURL = notificationToolkit.createQueryURL(c, "execute", "study", "anesthesiaRecovery", unclosedFilter); + + // Returns data. + this.unclosedRecoveries = new ArrayList<>(unclosedArray); + this.unclosedRecoveriesURL = viewQueryURL; + } + } + + public static class AnesthesiaRecoveryReviewReviewRequiredObject { + Container c; + User u; + NotificationToolkit notificationToolkit = new NotificationToolkit(); + NotificationToolkit.DateToolkit dateToolkit = new NotificationToolkit.DateToolkit(); + + // Constructor function. + public AnesthesiaRecoveryReviewReviewRequiredObject(Container currentContainer, User currentUser) { + this.c = currentContainer; + this.u = currentUser; + this.getRecoveriesWithReviewRequired(); + } + + // Find all anesthesia recoveries that have been opened, but not closed. + ArrayList> reviewRequiredRecoveries; + String reviewRequiredRecoveriesURL; + private void getRecoveriesWithReviewRequired() { + // Creates filter. + SimpleFilter reviewRequiredFilter = new SimpleFilter("qcstate/label", "Review Required", CompareType.EQUAL); + // Creates sort. + Sort mySort = new Sort("Id"); + // Creates columns to retrieve. + String[] targetColumns = new String[]{"Id", "recoveryId"}; // TODO: Change this to task after implementing TaskID (only needed if we remove recoveryId). + // Runs query. + ArrayList> openedArray = notificationToolkit.getTableMultiRowMultiColumnWithFieldKeys(c, u, "study", "anesthesiaRecovery", reviewRequiredFilter, mySort, targetColumns); + + // 1. Extract recoveryIds from closedArray into a string list (for query filtering below). + List reviewRequiredIds = openedArray.stream() + .map(map -> map.get("recoveryId")) + .filter(Objects::nonNull) + .toList(); + // 2. Creates a URL consisting of all review required ID's. CompareType.IN requries a semicolon separated string list. + String reviewRequiredIdsAsString = String.join(";", reviewRequiredIds); + SimpleFilter unclosedFilter = new SimpleFilter("recoveryId", reviewRequiredIdsAsString, CompareType.IN); + String viewQueryURL = notificationToolkit.createQueryURL(c, "execute", "study", "anesthesiaRecovery", unclosedFilter); + + // Returns data. + this.reviewRequiredRecoveries = new ArrayList<>(openedArray); + this.reviewRequiredRecoveriesURL = viewQueryURL; + } + } +} diff --git a/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/NotificationToolkit.java b/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/NotificationToolkit.java index 16bf1dec6..e935e34e8 100644 --- a/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/NotificationToolkit.java +++ b/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/NotificationToolkit.java @@ -855,6 +855,7 @@ public ArrayList sortSetWithNulls(Set setToSort) { // ColonyInformationObject > getLivingAnimalsWithMultipleActiveHousingRecords // ColonyInformationObject > getAllRecordsWithPotentialHousingConditionProblems // ColonyInformationObject > getAllRecordsWithCalculatedStatusFieldProblems + // AnesthesiaRecoveryReviewNotificationObject > getUnclosedAnesthesiaRecoveries /** * Creates a URL for a query matching user arguments. * WARNING: This should only be used with a SimpleFilter that has clauses containing only one field key. You can use multiple clauses and multiple values for each, but each clause should only have one key. @@ -888,22 +889,27 @@ else if (executeOrUpdate.equals("update")) { // Gets clause key. FieldKey clauseKey = currentClause.getFieldKeys().get(0); //TODO: Add in comment that this should only be used with clauses containing one field key for each clause. - // Gets clause value. - StringBuilder clauseValue = new StringBuilder(); - if (currentClause.getParamVals() != null) { - for (Object paramValue : currentClause.getParamVals()) { - clauseValue.append(paramValue.toString()); -// clauseValue.append(";"); - } - } - - // Gets clause compare. + // Gets clause compare and value. CompareType clauseCompare = null; + StringBuilder clauseValue = new StringBuilder(); if (currentClause instanceof CompareType.CompareClause) { clauseCompare = ((CompareType.CompareClause)currentClause).getCompareType(); + // Gets clause value. + if (currentClause.getParamVals() != null) { + for (Object paramValue : currentClause.getParamVals()) { + clauseValue.append(paramValue.toString()); + } + } } else if (currentClause instanceof SimpleFilter.InClause) { clauseCompare = ((SimpleFilter.InClause) currentClause).getCompareType(); + // Gets clause value. + if (currentClause.getParamVals() != null) { + for (Object paramValue : currentClause.getParamVals()) { + clauseValue.append(paramValue.toString()); + clauseValue.append(";"); + } + } } else { return ""; diff --git a/WNPRC_EHR/src/org/labkey/wnprc_ehr/table/WNPRC_EHRCustomizer.java b/WNPRC_EHR/src/org/labkey/wnprc_ehr/table/WNPRC_EHRCustomizer.java index 3a8b2ab58..85274d963 100644 --- a/WNPRC_EHR/src/org/labkey/wnprc_ehr/table/WNPRC_EHRCustomizer.java +++ b/WNPRC_EHR/src/org/labkey/wnprc_ehr/table/WNPRC_EHRCustomizer.java @@ -101,9 +101,14 @@ else if (table.getName().equalsIgnoreCase("breeding_encounters") && table.getSch } else if (matches(table, "wnprc", "animal_requests")) { customizeAnimalRequestsTable((AbstractTableInfo) table); } + else if (matches(table, "study", "anesthesiaRecovery")) { + customizeAnesthesiaRecoveryTable((AbstractTableInfo) table); + } + else if (matches(table, "wnprc_ios_app", "session_log")) { + customizeSessionLogTable((AbstractTableInfo) table); + } else if (table.getName().equalsIgnoreCase("waterOrders")) appendEnddateFuture((AbstractTableInfo) table, "enddate"); - } } @@ -315,6 +320,196 @@ private void customizeFeedingTable(AbstractTableInfo ti) } + private void customizeAnesthesiaRecoveryTable(AbstractTableInfo ti) { + // Defines new 'start time' customized column and display name. + String recoveryStartTimeColumnName = "recoveryStartTime"; + String recoveryStartTimeDisplayName = "Recovery Start Time"; + String recoveryStartTimeDescription = "This column shows the calculated original start time for this specific recovery (the date/time the first observation was made)."; + // Creates SQL script to define what to show in column. + SQLFragment sql = new SQLFragment("(SELECT sub.date FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation != 'Imported' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId") + .append(" ORDER BY sub.created ASC LIMIT 1)"); + // Compiles data and creates the new column to insert. + ExprColumn newCol = new ExprColumn(ti, recoveryStartTimeColumnName, sql, JdbcType.TIMESTAMP); + newCol.setLabel(recoveryStartTimeDisplayName); + newCol.setDescription(recoveryStartTimeDescription); + ti.addColumn(newCol); + + // Defines new 'taskId' customized column and display name. + String taskIdColumnName = "taskid"; + String taskIdDisplayName = "Task ID"; + String taskIdDescription = "This column shows the task corresponding to this row's recoveryId."; + // Creates SQL script to define what to show. + SQLFragment taskIdSql = new SQLFragment(ExprColumn.STR_TABLE_ALIAS + ".recoveryId"); + // Compiles data and creates new column to insert. + ExprColumn taskIdColumn = new ExprColumn(ti, taskIdColumnName, taskIdSql, JdbcType.VARCHAR); + taskIdColumn.setLabel(taskIdDisplayName); + taskIdColumn.setDescription(taskIdDescription); + ti.addColumn(taskIdColumn); + + // Defines new 'recovery reason' customized column and display name, then sets the value so all entries match data in the 'Imported' row. + String recoveryReasonColumnName = "recoveryReasonFinal"; + String recoveryReasonDisplayName = "Recovery Reason Final"; + String recoveryReasonDescription = "This column shows the calculated original recovery reason for this specific recovery (the recoveryReason assigned when the recovery was first imported)."; + // Creates SQL script to define what to show in column. + SQLFragment recoveryReasonSql = new SQLFragment("(SELECT sub.recoveryReason FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation = 'Imported' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId") + .append(" ORDER BY sub.created ASC LIMIT 1)"); + // Compiles data and creates the new column to insert. + ExprColumn recoveryReasonColumn = new ExprColumn(ti, recoveryReasonColumnName, recoveryReasonSql, JdbcType.VARCHAR); + recoveryReasonColumn.setLabel(recoveryReasonDisplayName); + recoveryReasonColumn.setDescription(recoveryReasonDescription); + ti.addColumn(recoveryReasonColumn); + + // Defines new 'cage final' customized column and display name, then sets the value so all entries match data in the 'Imported' row. + String cageColumnName = "cageFinal"; + String cageDisplayName = "Cage Final"; + String cageDescription = "This column shows the calculated original cage for this specific recovery (the cage assigned when the recovery was first imported)."; + // Creates SQL script to define what to show in column. + SQLFragment cageSql = new SQLFragment("(SELECT sub.cage FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation = 'Imported' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId") + .append(" ORDER BY sub.created ASC LIMIT 1)"); + // Compiles data and creates the new column to insert. + ExprColumn cageColumn = new ExprColumn(ti, cageColumnName, cageSql, JdbcType.VARCHAR); + recoveryReasonColumn.setLabel(cageDisplayName); + recoveryReasonColumn.setDescription(cageDescription); + ti.addColumn(cageColumn); + + // Defines new 'location final' customized column and display name, then sets the value so all entries match data in the 'Imported' row. + String locationColumnName = "locationFinal"; + String locationDisplayName = "Location Final"; + String locationDescription = "This column shows the calculated original description for this specific recovery (the description assigned when the recovery was first imported)."; + // Creates SQL script to define what to show in column. + SQLFragment locationSql = new SQLFragment("(SELECT sub.location FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation = 'Imported' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId") + .append(" ORDER BY sub.created ASC LIMIT 1)"); + // Compiles data and creates the new column to insert. + ExprColumn locationColumn = new ExprColumn(ti, locationColumnName, locationSql, JdbcType.VARCHAR); + recoveryReasonColumn.setLabel(locationDisplayName); + recoveryReasonColumn.setDescription(locationDescription); + ti.addColumn(locationColumn); + + // Defines new 'room final' customized column and display name, then sets the value so all entries match data in the 'Imported' row. + String roomColumnName = "roomFinal"; + String roomDisplayName = "Room Final"; + String roomDescription = "This column shows the calculated original room for this specific recovery (the room assigned when the recovery was first imported)."; + // Creates SQL script to define what to show in column. + SQLFragment roomSql = new SQLFragment("(SELECT sub.room FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation = 'Imported' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId") + .append(" ORDER BY sub.created ASC LIMIT 1)"); + // Compiles data and creates the new column to insert. + ExprColumn roomColumn = new ExprColumn(ti, roomColumnName, roomSql, JdbcType.VARCHAR); + recoveryReasonColumn.setLabel(roomDisplayName); + recoveryReasonColumn.setDescription(roomDescription); + ti.addColumn(roomColumn); + + + + + + // Defines new 'group id' customized column and display name, then sets the value so all entries match data in the 'Imported' row. + String groupIdColumnName = "groupIdFinal"; + String groupIdDisplayName = "Group ID Final"; + String groupIdDescription = "This column shows the calculated original group ID for this specific recovery (the groupID assigned when the first observation was made)."; + // Creates SQL script to define what to show in column. + SQLFragment groupIdSql = new SQLFragment("(SELECT sub.groupId FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation = 'Imported' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId") + .append(" ORDER BY sub.created ASC LIMIT 1)"); + // Compiles data and creates the new column to insert. + ExprColumn groupIdCol = new ExprColumn(ti, groupIdColumnName, groupIdSql, JdbcType.VARCHAR); + groupIdCol.setLabel(groupIdDisplayName); + groupIdCol.setDescription(groupIdDescription); + ti.addColumn(groupIdCol); + + // Defines new 'total recovery time' customized column and display name. + String totalRecoveryTimeName = "totalRecoveryTime"; + String totalRecoveryTimeDisplayName = "Total Recovery Time"; + String totalRecoveryTimeDescription = "This column shows the calculated total recovery time for this specific recovery."; + // Creates SQL script to define what to show in column. + SQLFragment totalRecoveryTimeSql = new SQLFragment("EXTRACT(EPOCH FROM (") + // End Time: 'Fully Recovered'. + .append("(SELECT sub.date FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation = 'Fully Recovered' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId) - ") + // Start Time: First non-'Imported' observation. + .append("(SELECT MIN(sub.date) FROM ") + .append(ti.getFromSQL("sub")) + .append(" WHERE sub.observation != 'Imported' AND sub.recoveryId = ") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".recoveryId)") + .append(")) / 60"); + // Compiles data and creates the new column. + ExprColumn totalRecoveryTimeCol = new ExprColumn(ti, totalRecoveryTimeName, totalRecoveryTimeSql, JdbcType.DOUBLE); + totalRecoveryTimeCol.setLabel(totalRecoveryTimeDisplayName); + totalRecoveryTimeCol.setDescription(totalRecoveryTimeDescription); + // Creates a display renderer. + totalRecoveryTimeCol.setDisplayColumnFactory(colInfo -> new DataColumn(colInfo) { + @Override + public @NotNull HtmlString getFormattedHtml(RenderContext ctx) { + Object value = getValue(ctx); + if (value == null) return HtmlString.EMPTY_STRING; + double totalMinutes = ((Number) value).doubleValue(); + long hours = (long) (totalMinutes / 60); + long minutes = Math.round(totalMinutes % 60); + // Handle edge case where rounding minutes up hits 60. + if (minutes == 60) { + hours += 1; + minutes = 0; + } + return HtmlString.of(String.format("%d:%02d", hours, minutes)); + } + }); + + ti.addColumn(totalRecoveryTimeCol); + } + + private void customizeSessionLogTable(AbstractTableInfo ti) { + // 1. Defines new customized column and display name. + String numRecordsColumnName = "numRecords"; + String numRecordsDisplayName = "Number of Records"; + String numRecordsDescription = "This column shows the number of records returned from the current query."; + + // 2. Gets a reference to the wnprc schema's session log. + UserSchema wnprcSchema = getUserSchema(ti, "wnprc"); + if (wnprcSchema != null) { + TableInfo ogSessionLog = wnprcSchema.getTable("session_log"); + if (ogSessionLog != null) { + // 3. Creates SQL script to define what to show in our new column. + SQLFragment sql = new SQLFragment("(SELECT sub.number_of_records FROM ") + .append(ogSessionLog.getFromSQL("sub")) + .append(" WHERE sub.rowid = CAST(") + .append(ExprColumn.STR_TABLE_ALIAS) + .append(".original_row_id AS INTEGER) ORDER BY sub.created ASC LIMIT 1)"); + + // 4. Compiles and assigns our new column. + ExprColumn newCol = new ExprColumn(ti, numRecordsColumnName, sql, JdbcType.INTEGER); + newCol.setLabel(numRecordsDisplayName); + newCol.setDescription(numRecordsDescription); + ti.addColumn(newCol); + } + } + } + private void customizeBirthTable(AbstractTableInfo ti) { var cond = ti.getMutableColumn("cond"); diff --git a/wnprc_ios_app/README.md b/wnprc_ios_app/README.md new file mode 100644 index 000000000..2beeedacd --- /dev/null +++ b/wnprc_ios_app/README.md @@ -0,0 +1 @@ +This is a module that communicates with the PrimatePal iOS app. \ No newline at end of file diff --git a/wnprc_ios_app/build.gradle b/wnprc_ios_app/build.gradle new file mode 100644 index 000000000..982038bdd --- /dev/null +++ b/wnprc_ios_app/build.gradle @@ -0,0 +1,21 @@ +// /* +// * Copyright (c) 2025 LabKey Corporation +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// */ + +import org.labkey.gradle.util.BuildUtils + +plugins { + id 'org.labkey.build.module' +} diff --git a/wnprc_ios_app/module.properties b/wnprc_ios_app/module.properties new file mode 100644 index 000000000..fcab70df1 --- /dev/null +++ b/wnprc_ios_app/module.properties @@ -0,0 +1,22 @@ +# /* +# * Copyright (c) 2025 LabKey Corporation +# * +# * Licensed under the Apache License, Version 2.0 (the "License"); +# * you may not use this file except in compliance with the License. +# * You may obtain a copy of the License at +# * +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * Unless required by applicable law or agreed to in writing, software +# * distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. +# */ + +ModuleClass: org.labkey.wnprc_ios_app.wnprc_ios_appModule +Name: wnprc_ios_app +ManageVersion: false +SupportedDatabases: pgsql +#SchemaVersion: 25.000 +BuildType: Production \ No newline at end of file diff --git a/wnprc_ios_app/resources/schemas/dbscripts/postgresql/wnprc_ios_app-0.000-26.000.sql b/wnprc_ios_app/resources/schemas/dbscripts/postgresql/wnprc_ios_app-0.000-26.000.sql new file mode 100644 index 000000000..cd1a40d1a --- /dev/null +++ b/wnprc_ios_app/resources/schemas/dbscripts/postgresql/wnprc_ios_app-0.000-26.000.sql @@ -0,0 +1,129 @@ +-- This tracks the official SQL script that will be used when migrating to production for the first time. + +--Creates the schema to hold tables for the wnprc ios app. +CREATE SCHEMA IF NOT EXISTS wnprc_ios_app; + + + + + +-- Creates 'Reported Issues' dataset. +DROP TABLE IF EXISTS wnprc_ios_app.reported_issues; +CREATE TABLE wnprc_ios_app.reported_issues ( + -- Default LabKey fields. + rowid serial NOT NULL, + container entityid NOT NULL, + createdby userid NOT NULL, + created TIMESTAMP NOT NULL, + modifiedby userid NOT NULL, + modified TIMESTAMP NOT NULL, + + -- Issue details. + issue_description varchar(4000) NOT NULL, + + -- Dev details. + dev_comments varchar(4000), + status varchar(100) NOT NULL, + + -- Primary key. + CONSTRAINT PK_reported_issues PRIMARY KEY (rowid) +); + + + + + +-- Creates 'Animal Abstract Preferences' dataset. +DROP TABLE IF EXISTS wnprc_ios_app.user_animal_abstract_preferences; +CREATE TABLE wnprc_ios_app.user_animal_abstract_preferences ( +-- Default LabKey fields. +container entityid NOT NULL, +createdby userid NOT NULL, +created TIMESTAMP NOT NULL, +modifiedby userid NOT NULL, +modified TIMESTAMP NOT NULL, + +-- Preferences. +show_id BOOLEAN NOT NULL DEFAULT TRUE, +show_gender BOOLEAN NOT NULL DEFAULT TRUE, +show_availability BOOLEAN NOT NULL DEFAULT TRUE, +show_room BOOLEAN NOT NULL DEFAULT TRUE, +show_cage BOOLEAN NOT NULL DEFAULT TRUE, +show_condition BOOLEAN NOT NULL DEFAULT TRUE, +show_num_animals_in_cage BOOLEAN NOT NULL DEFAULT TRUE, +show_status BOOLEAN NOT NULL DEFAULT TRUE, +show_age BOOLEAN NOT NULL DEFAULT TRUE, +show_birth BOOLEAN NOT NULL DEFAULT TRUE, +show_dam BOOLEAN NOT NULL DEFAULT TRUE, +show_sire BOOLEAN NOT NULL DEFAULT TRUE, +show_tb_date BOOLEAN NOT NULL DEFAULT TRUE, +show_prepaid BOOLEAN NOT NULL DEFAULT TRUE, +show_mgap_ids BOOLEAN NOT NULL DEFAULT TRUE, +show_most_recent_weight BOOLEAN NOT NULL DEFAULT TRUE, +show_most_recent_weight_date BOOLEAN NOT NULL DEFAULT TRUE, +show_hold BOOLEAN NOT NULL DEFAULT TRUE, +show_medical BOOLEAN NOT NULL DEFAULT TRUE, +show_current_behaviors BOOLEAN NOT NULL DEFAULT TRUE, +show_most_recent_alopecia_score BOOLEAN NOT NULL DEFAULT TRUE, +show_most_recent_body_condition_score BOOLEAN NOT NULL DEFAULT TRUE, +show_origin BOOLEAN NOT NULL DEFAULT TRUE, +show_geographic_origin BOOLEAN NOT NULL DEFAULT TRUE, +show_ancestry BOOLEAN NOT NULL DEFAULT TRUE, +show_most_recent_arrival BOOLEAN NOT NULL DEFAULT TRUE, +show_most_recent_departure BOOLEAN NOT NULL DEFAULT TRUE, +show_death BOOLEAN NOT NULL DEFAULT TRUE, +show_remark BOOLEAN NOT NULL DEFAULT TRUE, +show_mgap_sequence_types BOOLEAN NOT NULL DEFAULT TRUE, + +-- Primary key. +target_user userid NOT NULL, +CONSTRAINT PK_user_animal_abstract_preferences PRIMARY KEY (target_user) +); + + + + + +-- Creates 'Session Log' dataset. +DROP TABLE IF EXISTS wnprc_ios_app.session_log; +CREATE TABLE wnprc_ios_app.session_log ( +-- Default LabKey fields. +container entityid NOT NULL, +createdby userid NOT NULL, +created TIMESTAMP NOT NULL, +modifiedby userid NOT NULL, +modified TIMESTAMP NOT NULL, + +-- Request details. +original_row_id varchar(100) NOT NULL, +query_name varchar(4000) NOT NULL, +request_type varchar(100) NOT NULL, +errors_occurred BOOLEAN NOT NULL DEFAULT FALSE, +dev_comments varchar(4000), +error_description varchar(4000), + +-- Primary key. +CONSTRAINT PK_wnprc_ios_app PRIMARY KEY (original_row_id) +); + + + + + +-- Creates 'Push Notifications' dataset. +DROP TABLE IF EXISTS wnprc_ios_app.push_notifications; +CREATE TABLE wnprc_ios_app.push_notifications ( +-- Default LabKey fields. +container entityid NOT NULL, +createdby userid NOT NULL, +created TIMESTAMP NOT NULL, +modifiedby userid NOT NULL, +modified TIMESTAMP NOT NULL, + +-- Push details. +push_token varchar(4000), + +-- Primary key. +target_user userid NOT NULL, +CONSTRAINT PK_push_notifications PRIMARY KEY (target_user) +); diff --git a/wnprc_ios_app/resources/schemas/wnprc_ios_app.xml b/wnprc_ios_app/resources/schemas/wnprc_ios_app.xml new file mode 100644 index 000000000..2efd9a22f --- /dev/null +++ b/wnprc_ios_app/resources/schemas/wnprc_ios_app.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
\ No newline at end of file diff --git a/wnprc_ios_app/resources/views/begin.html b/wnprc_ios_app/resources/views/begin.html new file mode 100644 index 000000000..16f2c823f --- /dev/null +++ b/wnprc_ios_app/resources/views/begin.html @@ -0,0 +1 @@ +

PrimatePal admin page will be shown here.

\ No newline at end of file diff --git a/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appContainerListener.java b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appContainerListener.java new file mode 100644 index 000000000..5c3b53e86 --- /dev/null +++ b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appContainerListener.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.wnprc_ios_app; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager.ContainerListener; +import org.labkey.api.security.User; +import java.util.Collections; +import java.util.Collection; + +import java.beans.PropertyChangeEvent; + +public class wnprc_ios_appContainerListener implements ContainerListener +{ + @Override + public void containerCreated(Container c, User user) + { + } + + @Override + public void containerDeleted(Container c, User user) + { + } + + @Override + public void propertyChange(PropertyChangeEvent evt) + { + } + + @Override + public void containerMoved(Container c, Container oldParent, User user) + { + } + + @NotNull @Override + public Collection canMove(Container c, Container newParent, User user) + { + return Collections.emptyList(); + } +} \ No newline at end of file diff --git a/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appController.java b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appController.java new file mode 100644 index 000000000..dc242fd6e --- /dev/null +++ b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appController.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2025 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.wnprc_ios_app; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONObject; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.SimpleApiJsonForm; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.query.QueryService; +import org.labkey.api.security.ActionNames; +import org.labkey.api.security.RequiresLogin; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.springframework.validation.BindException; +import org.springframework.web.servlet.ModelAndView; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.List; + +public class wnprc_ios_appController extends SpringActionController +{ + private static Logger _log = LogManager.getLogger(wnprc_ios_appController.class); + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(wnprc_ios_appController.class); + public static final String NAME = "wnprc_ios_app"; + + public wnprc_ios_appController() + { + setActionResolver(_actionResolver); + } + +// @RequiresPermission(ReadPermission.class) +// public class BeginAction extends SimpleViewAction +// { +// public ModelAndView getView(Object o, BindException errors) +// { +// return new JspView("/org/labkey/wnprc_ios_app/view/hello.jsp"); +// } +// +// public void addNavTrail(NavTree root) { } +// } + + + +// // This function is muted until needed. +// // This function always returns 'true'. It is a placeholder if we decide to implement any future validation for this dataset. +// @ActionNames("updatePushNotification") +//// @RequiresLogin +// @RequiresPermission(ReadPermission.class) +// public static class UpdatePushNotificationAction extends MutatingApiAction { +// @Override +// public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception { +// // Creates variables. +// ApiSimpleResponse response = new ApiSimpleResponse(); +// +// // Retrieves passed-in arguments. +// JSONObject myForm = form.getJsonObject(); +// String pushToken = myForm.getString("push_token").toString(); +// String targetUser = myForm.getString("target_user").toString(); +// String insertOrUpdate = myForm.getString("insert_or_update").toString(); +// +// // Code any necessary validation here. +// // Verifies user is either inserting (with no previous preferences) or updating (with previous preferences). +// SimpleFilter pushNotificationsFilter = new SimpleFilter("target_user", targetUser, CompareType.EQUAL); +// TableInfo ti = QueryService.get().getUserSchema(getUser(), getContainer(), "wnprc_ios_app").getTable("push_notifications"); +// TableSelector myTable = new TableSelector(ti, PageFlowUtil.set("target_user"), pushNotificationsFilter, null); +// Map[] rows = myTable.getMapArray(); +// if (insertOrUpdate.equals("insert")) { +// if (rows.length > 0) { +// response.put("detailedResponse", "UpdatePushNotificationAction API: Insert failed due to multiple rows existing."); +// response.put("success", false); +// return response; +// } +// } +// else if (insertOrUpdate.equals("update")) { +// if (rows.length != 1) { +// response.put("detailedResponse", "UpdatePushNotificationAction API: Update failed due to not having 1 existing row."); +// response.put("success", false); +// return response; +// } +// } +// +// // Successfully completes validation. +// _log.info("UpdatePushNotificationAction API: pushToken=" + pushToken + ", targetUser=" + targetUser + ", insertOrUpdate=" + insertOrUpdate); +// response.put("detailedResponse", "UpdatePushNotificationAction API: Push notification record validated successfully."); +// response.put("success", true); +// return response; +// } +// } + + + +// // This function is muted until needed. +// // This function always returns 'true'. It is a placeholder if we decide to implement any future validation for this dataset. +// @RequiresLogin +// public static class UpdateReportedIssuesAction extends MutatingApiAction { +// @Override +// public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception { +// // Creates variables. +// ApiSimpleResponse response = new ApiSimpleResponse(); +// +// // Retrieves passed-in arguments. +// JSONObject myForm = form.getJsonObject(); +// String issueDescription = myForm.getString("issue_description").toString(); +// String issueStatus = myForm.getString("status").toString(); +// +// _log.info("UpdateReportedIssuesAction API: issueDescription=" + issueDescription + ", issueStatus=" + issueStatus); +// // Code any necessary validation here. +// +// response.put("detailedResponse", "UpdateReportedIssuesAction API: Reported issue validated successfully."); +// response.put("success", true); +// return response; +// } +// } + + + +// // This function is muted until needed. +// // This function always returns 'true'. It is a placeholder if we decide to implement any future validation for this dataset. +// @RequiresLogin +// public static class UpdateSessionLogAction extends MutatingApiAction +// { +// @Override +// public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception +// { +// // Creates variables. +// ApiSimpleResponse response = new ApiSimpleResponse(); +// +// // Retrieves passed-in arguments. +// JSONObject myForm = form.getJsonObject(); +// String startTime = myForm.getString("start_time").toString(); +// String endTime = myForm.getString("end_time").toString(); +// String schemaName = myForm.getString("schema_name").toString(); +// String queryName = myForm.getString("query_name").toString(); +// String numberOfRecords = myForm.getString("number_of_records").toString(); +// String errorsOccurred = myForm.getString("errors_occurred").toString(); +// String createdBy = myForm.getString("createdby").toString(); +// String userAgent = myForm.getString("user_agent").toString(); +// +// _log.info("UpdateSessionLog API: startTime=" + startTime + ", endTime=" + endTime + ", schemaName=" + schemaName + ", queryName=" + queryName + ", numberOfRecords=" + numberOfRecords + ", errorsOccurred=" + errorsOccurred + ", createdBy=" + createdBy + ", userAgent=" + userAgent); +// // Code any necessary validation here. +// +// response.put("detailedResponse", "UpdateSessionLog API: Session log record validated successfully."); +// response.put("success", true); +// return response; +// } +// } + + + +// // This function is muted until needed. +// @RequiresLogin +// public static class UpdateUserAnimalAbstractPreferencesAction extends MutatingApiAction +// { +// @Override +// public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception +// { +// // Creates variables. +// ApiSimpleResponse response = new ApiSimpleResponse(); +//// NotificationToolkit notificationToolkit = new NotificationToolkit(); +// +// // Retrieves passed-in arguments. +// JSONObject myForm = form.getJsonObject(); +// int targetUser = myForm.getInt("target_user"); +// String insertOrUpdate = myForm.getString("insert_or_update").toString(); +// +// // Validates that the preferences to change are supported. +// String[] supportedPreferences = { +// "show_id", +// "show_gender", +// "show_availability", +// "show_room", +// "show_cage", +// "show_condition", +// "show_num_animals_in_cage", +// "show_status", +// "show_age", +// "show_birth", +// "show_dam", +// "show_sire", +// "show_tb_date", +// "show_prepaid", +// "show_mgap_ids", +// "show_most_recent_weight", +// "show_most_recent_weight_date", +// "show_hold", +// "show_medical", +// "show_current_behaviors", +// "show_most_recent_alopecia_score", +// "show_most_recent_body_condition_score", +// "show_origin", +// "show_geographic_origin", +// "show_ancestry", +// "show_most_recent_arrival", +// "show_most_recent_departure", +// "show_death", +// "show_remark", +// "show_mgap_sequence_types" +// }; +// List supportedPreferencesList = Arrays.asList(supportedPreferences); +// Iterator keys = myForm.keys(); +// Boolean hasUnsupportedPreferences = false; +// while (keys.hasNext()) { +// String key = keys.next(); +// if (!key.equals("target_user") && !key.equals("date_last_updated") && !key.equals("insert_or_update")) { +// if (!supportedPreferencesList.contains(key)) { +// hasUnsupportedPreferences = true; +// } +// } +// } +// if (hasUnsupportedPreferences) { +// response.put("detailedResponse", "UpdateUserAnimalAbstractPreferences API: Abstract preferences were unsupported."); +// response.put("success", false); +// return response; +// } +// +// // Verifies user is either inserting (with no previous preferences) or updating (with previous preferences). +// SimpleFilter animalAbstractPreferencesFilter = new SimpleFilter("target_user", targetUser, CompareType.EQUAL); +// TableInfo ti = QueryService.get().getUserSchema(getUser(), getContainer(), "wnprc_ios_app").getTable("user_animal_abstract_preferences"); +// TableSelector myTable = new TableSelector(ti, PageFlowUtil.set("target_user"), animalAbstractPreferencesFilter, null); +// Map[] rows = myTable.getMapArray(); +// if (insertOrUpdate.equals("insert")) { +// if (rows.length > 0) { +// response.put("detailedResponse", "UpdateUserAnimalAbstractPreferences API: Insert failed due to multiple rows existing."); +// response.put("success", false); +// return response; +// } +// } +// else if (insertOrUpdate.equals("update")) { +// if (rows.length != 1) { +// response.put("detailedResponse", "UpdateUserAnimalAbstractPreferences API: Update failed due to not having 1 existing row."); +// response.put("success", false); +// return response; +// } +// } +// +// // Successfully completes validation. +// _log.info("UpdateUserAnimalAbstractPreferencesAction API: targetUser=" + targetUser + ", insertOrUpdate=" + insertOrUpdate); +// response.put("detailedResponse", "UpdateUserAnimalAbstractPreferences API: Abstract preferences validated successfully."); +// response.put("success", true); +// return response; +// } +// } +} diff --git a/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appManager.java b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appManager.java new file mode 100644 index 000000000..14c3610e5 --- /dev/null +++ b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appManager.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.wnprc_ios_app; + +public class wnprc_ios_appManager +{ + private static final wnprc_ios_appManager _instance = new wnprc_ios_appManager(); + + private wnprc_ios_appManager() + { + // prevent external construction with a private default constructor + } + + public static wnprc_ios_appManager get() + { + return _instance; + } +} \ No newline at end of file diff --git a/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java new file mode 100644 index 000000000..3025a5961 --- /dev/null +++ b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.wnprc_ios_app; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbSchemaType; +//import org.labkey.api.ehr.EHRService; +import org.labkey.api.module.DefaultModule; +import org.labkey.api.module.Module; +import org.labkey.api.module.ModuleContext; +import org.labkey.api.module.SimpleModule; +import org.labkey.api.module.SpringModule; +import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.QuerySchema; +import org.labkey.api.query.QueryService; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.view.WebPartFactory; +//import org.labkey.wnprc_ehr.updates.ModuleUpdate; + +import javax.swing.*; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +public class wnprc_ios_appModule extends DefaultModule +{ + public static final String NAME = "wnprc_ios_app"; + + @Override + public String getName() + { + return NAME; + } + + @Override + public @Nullable Double getSchemaVersion() + { + return 25.001; + } + + @Override + public boolean hasScripts() + { + return true; + } + + @Override + @NotNull + protected Collection createWebPartFactories() + { + return Collections.emptyList(); + } + + @Override + protected void init() + { + addController(wnprc_ios_appController.NAME, wnprc_ios_appController.class); + } + + @Override + public void doStartup(ModuleContext moduleContext) + { + // add a container listener so we'll know when our container is deleted: + ContainerManager.addContainerListener(new wnprc_ios_appContainerListener()); + +// ModuleUpdate.onStartup(moduleContext, this); +// EHRService.get().registerTableCustomizer(this, org.labkey.wnprc_ehr.table.WNPRC_EHRCustomizer.class); + + DefaultSchema.registerProvider(wnprc_ios_appSchema.NAME, new DefaultSchema.SchemaProvider(this) + { + @Override + public @Nullable QuerySchema createSchema(DefaultSchema schema, Module module) + { + DbSchema dbSchema = DbSchema.get(wnprc_ios_appSchema.NAME, DbSchemaType.Module); + return QueryService.get().createSimpleUserSchema(dbSchema.getQuerySchemaName(), null, schema.getUser(), schema.getContainer(), dbSchema); + } + }); + } + + @Override + @NotNull + public Collection getSummary(Container c) + { + return Collections.emptyList(); + } + + @Override + @NotNull + public Set getSchemaNames() + { + return Collections.singleton(wnprc_ios_appSchema.NAME); + } + +} \ No newline at end of file diff --git a/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appSchema.java b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appSchema.java new file mode 100644 index 000000000..e5e636877 --- /dev/null +++ b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appSchema.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.wnprc_ios_app; + +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbSchemaType; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.dialect.SqlDialect; + +public class wnprc_ios_appSchema +{ + private static final wnprc_ios_appSchema _instance = new wnprc_ios_appSchema(); + // Schema name. + public static final String NAME = "wnprc_ios_app"; + // Table names to expose via schema browser. + public static final String PUSH_NOTIFICATIONS_TABLE_NAME = "push_notifications"; + public static final String SESSION_LOG_TABLE_NAME = "session_log"; + public static final String REPORTED_ISSUES_TABLE_NAME = "reported_issues"; + public static final String USER_ANIMAL_ABSTRACT_PREFERENCES_TABLE_NAME = "user_animal_abstract_preferences"; + + + + public static wnprc_ios_appSchema getInstance() + { + return _instance; + } + + private wnprc_ios_appSchema() + { + // private constructor to prevent instantiation from + // outside this class: this singleton should only be + // accessed via org.labkey.wnprc_ios_app.wnprc_ios_appSchema.getInstance() + } + + public DbSchema getSchema() + { + return DbSchema.get(NAME, DbSchemaType.Module); + } + + public SqlDialect getSqlDialect() + { + return getSchema().getSqlDialect(); + } + + public TableInfo getPushNotificationsTable() { + return getSchema().getTable(PUSH_NOTIFICATIONS_TABLE_NAME); + } + + public TableInfo getSessionLogTable() { + return getSchema().getTable(SESSION_LOG_TABLE_NAME); + } + + public TableInfo getReportedIssuesTable() { + return getSchema().getTable(REPORTED_ISSUES_TABLE_NAME); + } + + public TableInfo getUserAnimalAbstractPreferencesTableName() { + return getSchema().getTable(USER_ANIMAL_ABSTRACT_PREFERENCES_TABLE_NAME); + } +} From 76332f89145d9b1f914cbf65cf4004aacb3919a3 Mon Sep 17 00:00:00 2001 From: aschmidt34 <124093649+aschmidt34@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:07:55 -0500 Subject: [PATCH 2/3] 26.3 fb ios app production merge fix (#1009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated 4 files to fix the errors being thrown by TeamCity build. My anesthesiaRecovery dataset will be created manually after deployment, so these files needed to be updated (per Marty) to ensure the automated tests know what the dataset will look like. - datasets_manifest.xml: Added my dataset to the list (fixes TeamCity error: WNPRC_EHRTest) - datasets_metadata.xml: Listed all the columns in my dataset (fixes TeamCity error: WNPRC_EHRTest) - wnprcEhrTestStudyPolicy.xml: Added my table permissions (fixes TeamCity error: testMprDataEntry) - wnprc_ios_appModule.java: Updated my schema after changing my upgrade script to 0.000-26.000 (fixes TeamCity error: SchemaXMLTestCase) ## Rationale ## Related Pull Requests - ## Changes - --- .../study/datasets/datasets_manifest.xml | 3 + .../study/datasets/datasets_metadata.xml | 113 ++++++++++++++++++ .../wnprc_ehr/wnprcEhrTestStudyPolicy.xml | 6 + .../wnprc_ios_app/wnprc_ios_appModule.java | 2 +- 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_manifest.xml b/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_manifest.xml index af54a8e9d..06e96046e 100644 --- a/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_manifest.xml +++ b/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_manifest.xml @@ -232,5 +232,8 @@ + + + \ No newline at end of file diff --git a/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_metadata.xml b/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_metadata.xml index 82b520229..e1680dd5d 100644 --- a/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_metadata.xml +++ b/WNPRC_EHR/resources/referenceStudy/study/datasets/datasets_metadata.xml @@ -16334,4 +16334,117 @@ + + + + varchar + Id + Subject identifier + http://cpas.labkey.com/Study#ParticipantId + http://cpas.labkey.com/Study#ParticipantId + false + /ehr/participantView.view?participantId=${id} + + ptid + participantid + + + study + Animal + Id + + ALWAYS_OFF + 32 + + + integer + + + timestamp + + + timestamp + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + varchar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/WNPRC_EHR/test/sampledata/wnprc_ehr/wnprcEhrTestStudyPolicy.xml b/WNPRC_EHR/test/sampledata/wnprc_ehr/wnprcEhrTestStudyPolicy.xml index 69867afde..b91d36ea9 100644 --- a/WNPRC_EHR/test/sampledata/wnprc_ehr/wnprcEhrTestStudyPolicy.xml +++ b/WNPRC_EHR/test/sampledata/wnprc_ehr/wnprcEhrTestStudyPolicy.xml @@ -443,5 +443,11 @@ + + + + + + diff --git a/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java index 3025a5961..a8e555886 100644 --- a/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java +++ b/wnprc_ios_app/src/org/labkey/wnprc_ios_app/wnprc_ios_appModule.java @@ -53,7 +53,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 25.001; + return 26.001; } @Override From 2b7e2e83b9cfad2b871a77db5d0564b23b066597 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 14 Aug 2026 09:51:35 -0700 Subject: [PATCH 3/3] Handle missing file history when listing virology results to import (#1008) ## Rationale The "Import Results from File" list in the virology results grid could fail to render. Each candidate file's upload date comes from a webdav file-history lookup, and two paths through that lookup were broken: a file with no file system audit record threw on `history[0].data.date`, and the lookup's own failure callback called an undefined `reject`, throwing a ReferenceError instead of rejecting. Either one rejected the enclosing `Promise.all`, which had no rejection handler, so the window stayed behind a "Loading..." mask with no error shown. ## Changes - `getFileHistory` resolves instead of rejecting on a failed lookup, so one unreadable file no longer drops the whole import list. - Guarded the empty-history case; those files report their upload date as `Unknown` rather than a substituted timestamp. - Added a rejection handler to the `Promise.all` so failures hide the loading mask and surface an alert. - `getFileHistory` resolves `{name, uploaded}` rather than a positional `[filename, history]` pair. --- .../resources/web/ehr/ext3/ehrGridFormPanel.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/WNPRC_EHR/resources/web/ehr/ext3/ehrGridFormPanel.js b/WNPRC_EHR/resources/web/ehr/ext3/ehrGridFormPanel.js index 34c7899ab..829869620 100644 --- a/WNPRC_EHR/resources/web/ehr/ext3/ehrGridFormPanel.js +++ b/WNPRC_EHR/resources/web/ehr/ext3/ehrGridFormPanel.js @@ -1077,8 +1077,8 @@ EHR.ext.GridFormPanel = Ext.extend(Ext.Panel, if (extension === 'xlsx' || extension === 'xls') { // need to get the date it was uploaded, // since 'record' only provides the date when the actual file was created - promises.push(getFileHistory(virologyResultsFolder, record.data.name).then((history) => { - files.push({"name": history[0], "uploaded": history[1][0].data.date}) + promises.push(getFileHistory(virologyResultsFolder, record.data.name).then((file) => { + files.push(file) })) } @@ -1113,6 +1113,10 @@ EHR.ext.GridFormPanel = Ext.extend(Ext.Panel, importFromFileWindow.removeAll(); importFromFileWindow.add(selectFilePanel); importFromFileWindow.doLayout(); + }).catch((e) => { + Ext.Msg.hide(); + console.error(e); + Ext.Msg.alert('Error', 'Unable to build the list of files to import.'); }) }, @@ -1130,14 +1134,17 @@ EHR.ext.GridFormPanel = Ext.extend(Ext.Panel, } }); function getFileHistory(fileSystem, filename) { + // Always resolves so one unreadable file still leaves the rest importable. return new Promise(resolve => { fileSystem.getHistory({ path: '/' + filename, success: function(fileSystem,path,history) { - resolve([filename,history]); + // A file placed on the server outside of an upload has no audit record. + resolve({name: filename, uploaded: history && history.length ? history[0].data.date : null}); }, failure: function(f) { - reject(f); + console.error('Unable to read file history for ' + filename, f); + resolve({name: filename, uploaded: null}); } }) }); @@ -1307,7 +1314,7 @@ EHR.ext.GridFormPanel = Ext.extend(Ext.Panel, html: '' + file.name + '' }, { - html: '' + new Date(file.uploaded).format("Y-m-d H:i")+ '' + html: '' + (file.uploaded ? new Date(file.uploaded).format("Y-m-d H:i") : 'Unknown') + '' }, ];