// Copyright 2015 The Project Buendia Authors // // 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 distrib- // uted 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 // specific language governing permissions and limitations under the License. package org.openmrs.projectbuendia.webservices.rest; import org.apache.commons.lang.time.DateFormatUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.openmrs.Encounter; import org.openmrs.Patient; import org.openmrs.Provider; import org.openmrs.User; import org.openmrs.api.PatientService; import org.openmrs.api.ProviderService; import org.openmrs.api.context.Context; import org.openmrs.module.webservices.rest.SimpleObject; import org.openmrs.module.webservices.rest.web.RequestContext; import org.openmrs.module.webservices.rest.web.annotation.Resource; import org.openmrs.module.webservices.rest.web.resource.api.Creatable; import org.openmrs.module.webservices.rest.web.response.ConversionException; import org.openmrs.module.webservices.rest.web.response.GenericRestException; import org.openmrs.module.webservices.rest.web.response.IllegalPropertyException; import org.openmrs.module.webservices.rest.web.response.ResponseException; import org.openmrs.module.xforms.XformsQueueProcessor; import org.openmrs.module.xforms.util.XformsUtil; import org.openmrs.projectbuendia.Utils; import org.projectbuendia.openmrs.webservices.rest.RestController; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; import java.io.File; import java.io.IOException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import static org.openmrs.projectbuendia.webservices.rest.XmlUtil.getElementOrThrow; import static org.openmrs.projectbuendia.webservices.rest.XmlUtil.getElements; import static org.openmrs.projectbuendia.webservices.rest.XmlUtil.removeNode; /** * Resource for submitted "form instances" (filled-in forms). Write-only. * <p/> * <p>Accepts POST requests to [API root]/xforminstance with JSON data of the form: * <pre> * { * patient_id: "123", // patient ID assigned by medical center * patient_uuid: "24ae3-5", // patient UUID in OpenMRS * enterer_id: "1234-5", // person ID of the provider entering the data * date_entered: "2015-03-14T09:26:53.589Z", // date that the encounter was * // *entered* (not necessarily when observations were taken) * xml: "..." // XML contents of the form instance, as provided by ODK * } * </pre> * <p/> * <p>When creation is successful, the created XformInstance JSON is returned. * If an error occurs, the response will be in the form: * <pre> * { * "error": { * "message": "[error message]", * "code": "[breakpoint]", * "detail": "[stack trace]" * } * } * </pre> */ // TODO: Still not really sure what supportedClass to use here... can we omit it? @Resource(name = RestController.REST_VERSION_1_AND_NAMESPACE + "/xforminstances", supportedClass = SimpleObject.class, supportedOpenmrsVersions = "1.10.*,1.11.*") public class XformInstanceResource implements Creatable { static final RequestLogger logger = RequestLogger.LOGGER; // Everything not in this set is assumed to be a group of observations. private static final Set<String> KNOWN_CHILD_ELEMENTS = new HashSet<>(); private static final XformsQueueProcessor processor = new XformsQueueProcessor(); static { KNOWN_CHILD_ELEMENTS.add("header"); KNOWN_CHILD_ELEMENTS.add("patient"); KNOWN_CHILD_ELEMENTS.add("patient.patient_id"); KNOWN_CHILD_ELEMENTS.add("encounter"); KNOWN_CHILD_ELEMENTS.add("obs"); } private final PatientService patientService; private final ProviderService providerService; public XformInstanceResource() { patientService = Context.getPatientService(); providerService = Context.getProviderService(); } @Override public String getUri(Object instance) { // TODO Auto-generated method stub return null; } /** Accepts a submitted form instance. */ @Override public Object create(SimpleObject obj, RequestContext context) throws ResponseException { try { logger.request(context, this, "create", obj); Object result = createInner(obj, context); logger.reply(context, this, "create", result); return result; } catch (Exception e) { logger.error(context, this, "create", e); throw e; } } /** Accepts a submitted form instance. */ private Object createInner(SimpleObject post, RequestContext context) throws ResponseException { try { // We have to fix a few things before OpenMRS will accept the form. String xml = completeXform(convertUuidsToIds(post)); File file = File.createTempFile("projectbuendia", null); processor.processXForm(xml, file.getAbsolutePath(), true, context.getRequest()); } catch (IOException e) { throw new GenericRestException("Error storing xform data", e); } catch (ResponseException e) { // Just to avoid this being wrapped... throw e; } catch (Exception e) { throw new ConversionException("Error processing xform data", e); } Encounter encounter = guessEncounterFromXformSubmission(post); if (encounter == null) { // Just return the data we got, because this wasn't an encounter. return post; } SimpleObject returnJson = new SimpleObject(); EncounterResource.populateJsonProperties(encounter, returnJson); return returnJson; } /** * The Xforms code doesn't provide any information about the encounter that was created using * the Xforms submission, so we take an educated guess about the encounter that was created. The * educated guess is done by pulling the patient UUID and the provider UUID from the JSON input, * and then finding the latest encounter with that timestamp. If there wasn't one created in the * last two seconds, then we declare that the Xform submission didn't result in an encounter * being created. * <p> * <b>NOTE</b>: this heuristic won't work if: * <ul> * <li>The same user is logged in on two devices simultaneously, <b>and</b> * <li>That user submits different data for the same patient within a two second window. * </ul> * <p> * <b>TODO:</b> Make enhancements to the Xforms module so that we don't need to guess. */ private Encounter guessEncounterFromXformSubmission(SimpleObject postData) { String patientUuid = (String) postData.get("patient_uuid"); if (patientUuid == null) { return null; } Patient patient = patientService.getPatientByUuid(patientUuid); String entererUuid = (String) postData.get("enterer_uuid"); if (entererUuid == null) { throw new IllegalPropertyException("Enterer UUID must be set."); } Provider provider = providerService.getProviderByUuid(entererUuid); // Get all encounters with this patient and provider. List<Encounter> encounters = Context.getEncounterService().getEncounters( patient, null /* location */, null /* fromDate */, null /* toDate */, null /* enteredViaForms */, null /* encounterTypes */, Collections.singleton(provider), null /* visitTypes */, null /* visits */, false /* includeVoided */); // Filter based on creation time. Encounter latest = null; for (Encounter encounter : encounters) { if (latest == null || encounter.getDateCreated().after(latest.getDateCreated())) { latest = encounter; } } Date twoSecondsAgo = new Date(System.currentTimeMillis() - 2000); if (latest != null && latest.getDateCreated().before(twoSecondsAgo)) { // This encounter probably wasn't created from this Xforms submission. latest = null; } return latest; } /** * Fixes up the received XForm instance with various adjustments and additions * needed to get the observations into OpenMRS, e.g. include Patient ID, adjust * datetime formats, etc. */ static String completeXform(SimpleObject post) throws SAXException, IOException { String xml = (String) post.get("xml"); Integer patientId = (Integer) post.get("patient_id"); int entererId = (Integer) post.get("enterer_id"); String dateEntered = (String) post.get("date_entered"); dateEntered = workAroundClientIssue(dateEntered); Document doc = XmlUtil.parse(xml); // If we haven't been given a patient id, then the XForms processor will // create a patient then fill in the patient.patient_id in the DOM. // However, it won't actually create the node, just fill it in. // So whatever the case, make sure a patient.patient_id node exists. Element root = doc.getDocumentElement(); Element patient = getFirstElementOrCreate(doc, root, "patient"); Element patientIdElement = getFirstElementOrCreate(doc, patient, "patient.patient_id"); // Add patient element if we've been given a patient ID. // TODO: Is this okay if there's already a patient element? // Need to see how the Xforms module behaves. if (patientId != null) { patientIdElement.setTextContent(String.valueOf(patientId)); } // Modify header element Element header = getElementOrThrow(root, "header"); getElementOrThrow(header, "enterer").setTextContent(entererId + "^"); getElementOrThrow(header, "date_entered").setTextContent(dateEntered); // NOTE(kpy): We use a form_resource named <form-name>.xFormXslt to alter the translation // from XML to HL7 so that the encounter_datetime is recorded with a date and time. // (The default XSLT transform records only the date, not the time.) This means that // IF THE FORM IS RENAMED, THE FORM_RESOURCE MUST ALSO BE RENAMED, or the encounter // datetime will be recorded with only a date and the time will always be 00:00. // Extract the datetime and set it back, to reformat it to a format that OpenMRS // will accept, ensure it has a value that OpenMRS will accept, and also to // ensure that a datetime is filled in if missing. Date datetime = Utils.fixEncounterDateTime(getEncounterDatetime(doc)); // OpenMRS has trouble handling the encounter_datetime in the format we receive. // We must set the encounter_datetime to ensure it is properly formatted. setEncounterDatetime(doc, datetime); // TODO: we should also have some code here to ensure that the correct XSLT exists // for every form; otherwise we lose it on form rename. // Make sure that all observations are under the obs element, with appropriate attributes Element obs = getFirstElementOrCreate(doc, root, "obs"); obs.setAttribute("openmrs_concept", "1238^MEDICAL RECORD OBSERVATIONS^99DCT"); obs.setAttribute("openmrs_datatype", "ZZ"); for (Element element : getElements(root)) { if (!KNOWN_CHILD_ELEMENTS.contains(element.getLocalName())) { for (Element observation : getElements(element)) { obs.appendChild(observation); } removeNode(element); } } return XformsUtil.doc2String(doc); } /** * Fill in any missing "id" property by converting the UUID to a person_id. * <p> * <b>NOTE:</b> We're moving away from this model of allowing either {@code patient_id} or * {@code patient_uuid}, because {@code patient_id} is an internal-only identifier and shouldn't * be known by the client under any circumstances. We keep this behavior for the time being * because the tests currently depend on it. * <p> * TODO: replace this conversion logic with a hard requirement for both patient_uuid and * enterer_uuid. */ private SimpleObject convertUuidsToIds(SimpleObject post) { if (!post.containsKey("patient_id")) { String uuid = (String) post.get("patient_uuid"); if (uuid != null) { Patient patient = patientService.getPatientByUuid(uuid); if (patient == null) { throw new IllegalPropertyException("Patient UUID does not exist: " + uuid); } post.put("patient_id", patient.getPatientId()); } } if (!post.containsKey("enterer_id")) { String uuid = (String) post.get("enterer_uuid"); if (uuid == null) { throw new IllegalPropertyException("Enterer UUID must be set."); } User user = Utils.getUserFromProviderUuid(uuid); if (user == null) { throw new IllegalPropertyException("Provider UUID does not exist: " + uuid); } post.put("enterer_id", user.getUserId()); } return post; } /** * Handles the case where the Android client posts dates in * yyyyMMddTHHmmss.SSSZ format, which isn't ISO 8601. */ static String workAroundClientIssue(String fromClient) { // Just detect it by the lack of hyphens... if (fromClient.indexOf('-') == -1) { // Convert to yyyy-MM-ddTHH:mm:ss.SSS fromClient = new StringBuilder(fromClient) .insert(4, '-') .insert(7, '-') .insert(13, ':') .insert(16, ':') .toString(); } return fromClient; } // TODO: The following function is no longer used. Previously when // the tablets had better clocks than the server, we would adjust the // server's clock. Now the server is the authoritative time source, so // instead of pushing the server's clock forward, we use NTP to make the // tablets' clocks match the server's clock. // TODO: Remove adjustSystemClock when we feel confident about the new arrangement. /** * Searches for an element among the descendants of a given root element, * or creates it as an immediate child of the given element. */ private static Element getFirstElementOrCreate( Document doc, Element parent, String elementName) { NodeList patientElements = parent.getElementsByTagName(elementName); Element patient; if (patientElements == null || patientElements.getLength() == 0) { patient = doc.createElementNS(null, elementName); parent.appendChild(patient); } else { patient = (Element) patientElements.item(0); } return patient; } /** Extracts the encounter date from a submitted encounter. */ private static Date getEncounterDatetime(Document doc) { Element encounterDatetimeElement = getElementOrThrow( getElementOrThrow(doc.getDocumentElement(), "encounter"), "encounter.encounter_datetime"); // The code in completeXform converts the encounter_datetime using // ISO_DATETIME_TIME_ZONE_FORMAT.format() to ensure that the time zone // indicator contains a colon ("+01:00" instead of "+0100"); without // this colon, OpenMRS fails to parse the date. Surprisingly, a new // SimpleDateFormat(ISO_DATETIME_TIME_ZONE_FORMAT.getPattern() cannot // parse the string produced by ISO_DATETIME_TIME_ZONE_FORMAT.format(). // For safety we accept a few reasonable date formats, including // "yyyy-MM-dd'T'HH:mm:ss.SSSX", which can parse both kinds of time // zone indicator ("+01:00" and "+0100"). List<String> acceptablePatterns = Arrays.asList( "yyyy-MM-dd'T'HH:mm:ss.SSSX", "yyyy-MM-dd'T'HH:mm:ssX", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd" ); String datetimeText = encounterDatetimeElement.getTextContent(); for (String pattern : acceptablePatterns) { try { return new SimpleDateFormat(pattern).parse(datetimeText); } catch (ParseException e) { } } getLog().warn("No encounter_datetime found; using the current time"); return new Date(); } // VisibleForTesting /** Sets the encounter_datetime element to the given value. */ private static void setEncounterDatetime(Document doc, Date datetime) { // Format the encounter_datetime to ensure its timezone has a minute section. // See https://docs.google.com/document/d/1IT92y_YP7AnhpDfdelbS7huxNKswa4VSXYPzqbnkWik/edit // for an explanation why. Saxon datetime parsing can't cope with timezones without minutes. String formattedDatetime = DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT.format(datetime); getElementOrThrow( getElementOrThrow(doc.getDocumentElement(), "encounter"), "encounter.encounter_datetime") .setTextContent(formattedDatetime); } // VisibleForTesting @SuppressWarnings("unused") private static final Log getLog() { // TODO: Figure out why getLog(XformInstanceResource.class) gives no // log output. Using "org.openmrs.api" works, though. return LogFactory.getLog("org.openmrs.api"); } /** * Adjusts the system clock to ensure that the incoming encounter date * is not in the future. <b>This is a temporary hack</b> intended to work * around the fact that the Edison system clock does not stay running * while power is off; when it falls behind, a validation constraint in * OpenMRS starts rejecting all incoming encounters because they have * dates in the future. To work around this, we attempt to push the * system clock forward whenever we receive an encounter that appears to * be in the future. The system clock is set by a setuid executable * program "/usr/bin/buendia-pushclock". * @param xml */ private void adjustSystemClock(String xml) { final String PUSHCLOCK = "/usr/bin/buendia-pushclock"; if (!new File(PUSHCLOCK).exists()) { getLog().warn(PUSHCLOCK + " is missing; not adjusting the clock"); return; } try { Document doc = XmlUtil.parse(xml); Date date = getEncounterDatetime(doc); getLog().info("encounter_datetime parsed as " + date); // Convert to seconds. Allow up to 60 sec for truncation to // minutes and up to 60 sec for network and server latency. long timeSecs = (date.getTime()/1000) + 60 + 60; Process pushClock = Runtime.getRuntime().exec( new String[] {PUSHCLOCK, "" + timeSecs}); int code = pushClock.waitFor(); getLog().info("buendia-pushclock " + timeSecs + " -> exit code " + code); } catch (SAXException | IOException | InterruptedException e) { getLog().error("adjustSystemClock failed:", e); } } }