// 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.lang3.ArrayUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.openmrs.Concept; import org.openmrs.Encounter; import org.openmrs.Order; import org.openmrs.Patient; import org.openmrs.Provider; import org.openmrs.User; import org.openmrs.api.EncounterService; import org.openmrs.api.OrderService; 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.RestConstants; import org.openmrs.module.webservices.rest.web.annotation.Resource; import org.openmrs.module.webservices.rest.web.representation.Representation; import org.openmrs.module.webservices.rest.web.resource.api.Creatable; import org.openmrs.module.webservices.rest.web.resource.api.Deletable; import org.openmrs.module.webservices.rest.web.resource.api.Listable; import org.openmrs.module.webservices.rest.web.resource.api.Retrievable; import org.openmrs.module.webservices.rest.web.resource.api.Searchable; import org.openmrs.module.webservices.rest.web.resource.api.Updatable; import org.openmrs.module.webservices.rest.web.response.IllegalPropertyException; import org.openmrs.module.webservices.rest.web.response.ObjectNotFoundException; import org.openmrs.module.webservices.rest.web.response.ResponseException; import org.openmrs.projectbuendia.Utils; import org.projectbuendia.openmrs.api.ProjectBuendiaService; import org.projectbuendia.openmrs.api.SyncToken; import org.projectbuendia.openmrs.api.db.SyncPage; import org.projectbuendia.openmrs.webservices.rest.RestController; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import static org.apache.commons.lang3.ObjectUtils.firstNonNull; /** * Rest API for orders. * <p>Expected behavior: * <ul> * <li>GET /orders returns all orders * <li>GET /orders?since=[bookmark] returns all orders since the last server-provided bookmark. * <li>GET /orders/[UUID] returns a single order ({@link #retrieve(String, RequestContext)}) * <li>POST /orders?patient=[UUID] creates an order for a patient ({@link #create(SimpleObject, * RequestContext)} * <li>POST /orders/[UUID] updates a order ({@link #update(String, SimpleObject, RequestContext)}) * </ul> * <p/> * <p>Each operation handles Order resources in the following JSON form: * <p/> * <pre> * { * "uuid": "e5e755d4-f646-45b6-b9bc-20410e97c87c", // assigned by OpenMRS, not required for * creation * "voided": false, // If true, fields other than UUID are not guaranteed to be set. * "instructions": "Paracetamol 2 tablets 3x/day", * "start_millis": 1438711253000, * "stop_millis": 1438714253000 // optionally present * } * </pre> * (Results may also contain deprecated fields other than those described above.) * <p/> * <p>If an error occurs, the response will contain the following: * <pre> * { * "error": { * "message": "[error message]", * "code": "[breakpoint]", * "detail": "[stack trace]" * } * } * </pre> */ @Resource( name = RestController.REST_VERSION_1_AND_NAMESPACE + "/orders", supportedClass = Order.class, supportedOpenmrsVersions = "1.10.*,1.11.*" ) public class OrderResource implements Listable, Searchable, Retrievable, Creatable, Updatable, Deletable { private static final RequestLogger logger = RequestLogger.LOGGER; private static final Log log = LogFactory.getLog(OrderResource.class); // JSON fields public static final String PATIENT_UUID = "patient_uuid"; public static final String INSTRUCTIONS = "instructions"; public static final String START_MILLIS = "start_millis"; public static final String STOP_MILLIS = "stop_millis"; public static final String UUID = "uuid"; public static final String VOIDED = "voided"; public static final String ORDERER_UUID = "orderer_uuid"; private static final String FREE_TEXT_ORDER_UUID = "buendia-concept-free_text_order"; private static final int MAX_ORDERS_PER_PAGE = 500; // Allow all order actions except discontinues, because the client doesn't represent those. private static final Order.Action[] ALLOWABLE_ACTIONS = ArrayUtils.removeElement(Order.Action.values(), Order.Action.DISCONTINUE); private final PatientService patientService; private final OrderService orderService; private final ProviderService providerService; private final EncounterService encounterService; private final ProjectBuendiaService buendiaService; public OrderResource() { patientService = Context.getPatientService(); orderService = Context.getOrderService(); providerService = Context.getProviderService(); encounterService = Context.getEncounterService(); buendiaService = Context.getService(ProjectBuendiaService.class); } @Override public SimpleObject getAll(RequestContext context) throws ResponseException { return search(context); } private SimpleObject handleSync(RequestContext context) throws ResponseException { SyncToken syncToken = RequestUtil.mustParseSyncToken(context); Date requestTime = new Date(); SyncPage<Order> orders = buendiaService.getOrdersModifiedAtOrAfter( syncToken, syncToken != null /* includeVoided */, MAX_ORDERS_PER_PAGE /* maxResults */, ALLOWABLE_ACTIONS); List<SimpleObject> jsonResults = new ArrayList<>(); for (Order order : orders.results) { jsonResults.add(orderToJson(order)); } SyncToken newToken = SyncTokenUtils.clampSyncTokenToBufferedRequestTime(orders.syncToken, requestTime); // If we fetched a full page, there's probably more data available. boolean more = orders.results.size() == MAX_ORDERS_PER_PAGE; return ResponseUtil.createIncrementalSyncResults(jsonResults, newToken, more); } /** Serializes an order to JSON. */ private static SimpleObject orderToJson(Order order) { // The UUID we send to the client is actually the UUID of the order at the head of the // revision chain... Order rootOrder = Utils.getRootOrder(order); // but the data we supply comes from the latest revision in the chain. order = getLatestVersion(rootOrder); SimpleObject json = new SimpleObject(); if (order != null) { json.add(UUID, rootOrder.getUuid()); json.add(VOIDED, order.isVoided()); if (order.isVoided()) { return json; } json.add(PATIENT_UUID, order.getPatient().getUuid()); String instructions = order.getInstructions(); if (instructions != null) { json.add(INSTRUCTIONS, instructions); } Date start = order.getScheduledDate(); if (start != null) { json.add(START_MILLIS, start.getTime()); } Date stop = firstNonNull(order.getDateStopped(), order.getAutoExpireDate()); json.add(STOP_MILLIS, stop == null ? null : stop.getTime()); } return json; } @Override public SimpleObject search(RequestContext context) throws ResponseException { try { logger.request(context, this, "handleSync"); SimpleObject result = handleSync(context); logger.reply(context, this, "handleSync", result); return result; } catch (Exception e) { logger.error(context, this, "handleSync", e); throw e; } } @Override public Object create(SimpleObject json, RequestContext context) throws ResponseException { try { logger.request(context, this, "create", json); Object result = createInner(json); logger.reply(context, this, "create", result); return result; } catch (Exception e) { logger.error(context, this, "create", e); throw e; } } private Object createInner(SimpleObject json) throws ResponseException { Order order = jsonToOrder(json); orderService.saveOrder(order, null); return orderToJson(order); } /** Creates a new Order and a corresponding Encounter containing it. */ private Order jsonToOrder(SimpleObject json) { Order order = new Order(); populateFromJson(order, json); populateDefaultsForAllOrders(order); populateDefaultsForNewOrder(order); return order; } /** * Finds the last order in the chain containing the given order. We need to do this instead of * using {@link OrderService#getRevisionOrder(Order)} because {@code getRevisionOrder(Order)} * only gets orders that have an action of {@link org.openmrs.Order.Action#REVISE}. To use * {@code REVISE}, the previous order needs to have not expired, which we can't guarantee. * </ul> */ public static Order getLatestVersion(Order order) { // Construct a map of forward pointers using the backward pointers from getPreviousOrder(). Map<String, String> nextOrderUuids = new HashMap<>(); for (Order o : Context.getOrderService().getAllOrdersByPatient(order.getPatient())) { Order prev = o.getPreviousOrder(); if (prev != null) { nextOrderUuids.put(prev.getUuid(), o.getUuid()); } } // Walk forward until the end of the chain. String uuid = order.getUuid(); String nextUuid = nextOrderUuids.get(uuid); while (nextUuid != null) { uuid = nextUuid; nextUuid = nextOrderUuids.get(uuid); } return Context.getOrderService().getOrderByUuid(uuid); } /** * Populates data for an order that was created with the Buendia API. This should be done for * both new orders and revisions. */ private void populateDefaultsForAllOrders(Order order) { Provider orderer = order.getOrderer(); // Populate with a default orderer if none is supplied. if (orderer == null) { order.setOrderer(getProvider()); } // Will be null if `orderer` is null. User creator = Utils.getUserFromProvider(orderer); order.setEncounter(createEncounter(order.getPatient(), creator, new Date())); order.setCreator(creator); } /** * Populates data for a NEW order that was created with the Buendia API. This should not be used * for revisions, because it may overwrite data set by another source. */ private void populateDefaultsForNewOrder(Order order) { order.setOrderType(DbUtil.getMiscOrderType()); order.setCareSetting(orderService.getCareSettingByName("Outpatient")); order.setConcept(getFreeTextOrderConcept()); order.setUrgency(Order.Urgency.ON_SCHEDULED_DATE); } /** * Populates an {@link Order} from a JSON representation. Overwrites fields from the JSON where * this is valid (all fields except for {@link #PATIENT_UUID}). * @param order The order to update * @param json The JSON representation to draw updates from. * @return {@code true} if the Order was changed. */ private boolean populateFromJson(Order order, SimpleObject json) { boolean changed = false; for (Map.Entry<String, Object> entry : json.entrySet()) { final Object value = entry.getValue(); switch (entry.getKey()) { case PATIENT_UUID: { if (value == null) { throw new IllegalPropertyException(PATIENT_UUID + " cannot be null"); } if (!(value instanceof String)) { throw new IllegalPropertyException( "Illegal format for " + PATIENT_UUID + ", expected string"); } String patientUuid = (String) value; if (order.getPatient() != null) { if (Objects.equals(order.getPatient().getUuid(), patientUuid)) { // Patient hasn't changed, keep going continue; } throw new IllegalPropertyException("Can't modify " + PATIENT_UUID); } Patient patient = patientService.getPatientByUuid(patientUuid); if (patient == null) { throw new IllegalPropertyException( "Patient with UUID " + patientUuid + " does not exist."); } order.setPatient(patient); changed = true; } break; case INSTRUCTIONS: { if (!(value instanceof String)) { throw new IllegalPropertyException( "Illegal format for " + INSTRUCTIONS + ", expected string"); } if (Objects.equals(order.getInstructions(), value)) { // No change continue; } order.setInstructions((String) value); changed = true; } break; case START_MILLIS: { Date dateVal = objectToDate(value, START_MILLIS); if (Objects.equals(order.getScheduledDate(), dateVal)) { // No change continue; } order.setScheduledDate(dateVal); changed = true; } break; case STOP_MILLIS: { Date dateVal = objectToDate(value, STOP_MILLIS); if (Objects.equals(order.getAutoExpireDate(), dateVal)) { // No change continue; } order.setAutoExpireDate(dateVal); changed = true; } break; case ORDERER_UUID: { if (!(value instanceof String)) { throw new IllegalPropertyException( "Illegal format for " + ORDERER_UUID + ", expected string"); } order.setCreator(Utils.getUserFromProviderUuid((String) value)); order.setOrderer( providerService.getProviderByUuid((String) value)); } break; default: { log.warn( "Key '" + entry.getKey() + "' is not the name of an editable property"); } break; } } return changed; } private static Date objectToDate(Object value, String fieldName) { Long millis; try { millis = Utils.asLong(value); } catch (ClassCastException ex) { throw new IllegalPropertyException( "Illegal format for " + fieldName + ", expected number"); } return millis == null ? null : new Date(millis); } private Encounter createEncounter(Patient patient, User creator, Date encounterDateTime) { Encounter encounter = new Encounter(); encounter.setCreator(creator); encounter.setEncounterDatetime(encounterDateTime); encounter.setPatient(patient); encounter.setLocation(Context.getLocationService().getDefaultLocation()); encounter.setEncounterType(encounterService.getEncounterType("ADULTRETURN")); encounterService.saveEncounter(encounter); return encounter; } private Provider getProvider() { return providerService.getAllProviders(false).get(0); // omit retired } private static Concept getFreeTextOrderConcept() { return DbUtil.getConcept( "Order described in free text instructions", FREE_TEXT_ORDER_UUID, "N/A", "Misc"); } @Override public String getUri(Object instance) { Order order = (Order) instance; Resource res = getClass().getAnnotation(Resource.class); return RestConstants.URI_PREFIX + res.name() + "/" + order.getUuid(); } @Override public Object retrieve(String uuid, RequestContext context) throws ResponseException { try { logger.request(context, this, "retrieve", uuid); Object result = retrieveInner(uuid); logger.reply(context, this, "retrieve", result); return result; } catch (Exception e) { logger.error(context, this, "retrieve", e); throw e; } } private Object retrieveInner(String uuid) throws ResponseException { Order order = orderService.getOrderByUuid(uuid); if (order == null) { throw new ObjectNotFoundException(); } return orderToJson(order); } @Override public List<Representation> getAvailableRepresentations() { return Collections.singletonList(Representation.DEFAULT); } @Override public Object update(String uuid, SimpleObject simpleObject, RequestContext context) throws ResponseException { try { logger.request(context, this, "update", uuid + ", " + simpleObject); Object result = updateInner(uuid, simpleObject); logger.reply(context, this, "update", result); return result; } catch (Exception e) { logger.error(context, this, "update", e); throw e; } } /** * Updates an Order from the JSON representation. This method has the following semantics: * <ul> * <li>Any field that is set overwrites the current content * <li>Any field with a key but value == null deletes the current content * <li>Any field whose key is not present leaves the current content unchanged * <li>If the client requests a change that is illegal, that is an error. The whole call will * fail in this case. * </ul> */ private Object updateInner(String uuid, SimpleObject simpleObject) throws ResponseException { Order order = orderService.getOrderByUuid(uuid); if (order == null || order.isVoided()) { throw new ObjectNotFoundException(); } // Skip ahead to the latest order in the chain. order = getLatestVersion(order); Order revisedOrder = reviseOrder(order, simpleObject); // Don't update anything, there's no changes. if (revisedOrder == null) { return orderToJson(order); } orderService.saveOrder(revisedOrder, null); return orderToJson(revisedOrder); } /** Revises an order. Returns null if no changes were made. */ private Order reviseOrder(Order order, SimpleObject edits) { Order newOrder = order.cloneForRevision(); boolean changed = populateFromJson(newOrder, edits); if (!changed) { return null; } populateDefaultsForAllOrders(newOrder); // OpenMRS refuses to revise any order whose autoexpire date is in the past. Therefore, for // such orders, we have to store revisions with the NEW action instead of the REVISE action. if (orderHasExpired(order)) { newOrder.setAction(Order.Action.NEW); } return newOrder; } @Override public void delete(String uuid, String reason, RequestContext context) throws ResponseException { try { logger.request(context, this, "delete", uuid); deleteInner(uuid); logger.reply(context, this, "delete", "returned"); } catch (Exception e) { logger.error(context, this, "delete", e); throw e; } } public void deleteInner(String uuid) throws ResponseException { Order order = orderService.getOrderByUuid(uuid); if (order == null) { throw new ObjectNotFoundException(); } if (order.isVoided()) { return; } // Void all orders in the chain. Order orderToVoid = getLatestVersion(order); do { orderService.voidOrder(orderToVoid, "Voided by Buendia Android client in delete request"); orderToVoid = orderToVoid.getPreviousOrder(); } while (orderToVoid != null); } private static boolean orderHasExpired(Order order) { Date now = new Date(); return (order.getDateStopped() != null && order.getDateStopped().before(now)) || (order.getAutoExpireDate() != null && order.getAutoExpireDate().before(now)); } }