org.openmrs.projectbuendia.webservices.rest.XformInstanceResource.java Source code

Java tutorial

Introduction

Here is the source code for org.openmrs.projectbuendia.webservices.rest.XformInstanceResource.java

Source

// 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);
        }
    }
}