// 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.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.openmrs.Location;
import org.openmrs.Patient;
import org.openmrs.PatientIdentifier;
import org.openmrs.PatientIdentifierType;
import org.openmrs.PersonName;
import org.openmrs.User;
import org.openmrs.api.LocationService;
import org.openmrs.api.PatientService;
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.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.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.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* Rest API for patients.
* <p/>
* <p>Expected behavior:
* <ul>
* <li>GET /patients returns all patients ({@link #getAll(RequestContext)})
* <li>GET /patients/[UUID] returns a single patient ({@link #retrieve(String, RequestContext)})
* <li>GET /patients?id=[id] returns a search for a patient with the specified MSF (i.e. not
* OpenMRS) id.
* <li>POST /patients creates a patient ({@link #create(SimpleObject, RequestContext)}
* <li>POST /patients/[UUID] updates a patient ({@link #update(String, SimpleObject,
* RequestContext)})
* </ul>
* <p/>
* <p>Each operation handles Patient resources in the following JSON form:
* <p/>
* <pre>
* {
* "uuid": "e5e755d4-f646-45b6-b9bc-20410e97c87c", // assigned by OpenMRS, not required for
* creation
* "id": "567", // required unique id specified by user
* "sex": "F", // required as "M" or "F", unfortunately
* "birthdate": "1990-02-17", // required, but can be estimated
* "given_name": "Jane", // required, "Unknown" suggested if not known
* "family_name": "Doe", // required, "Unknown" suggested if not known
* "assigned_location": { // optional, but highly encouraged
* "uuid": "0a49d383-7019-4f1f-bf4b-875f2cd58964", // UUID of the patient's assigned location
* }
* },
* </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 + "/patients",
supportedClass = Patient.class,
supportedOpenmrsVersions = "1.10.*,1.11.*"
)
public class PatientResource implements Listable, Searchable, Retrievable, Creatable, Updatable {
private static final SimpleDateFormat PATIENT_BIRTHDATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd");
static {
PATIENT_BIRTHDATE_FORMAT.setTimeZone(Utils.UTC);
}
private static final int MAX_PATIENTS_PER_PAGE = 500;
private static final String FACILITY_NAME = "Kailahun"; // TODO: Use a real facility name.
static final RequestLogger logger = RequestLogger.LOGGER;
// JSON property names
private static final String ID = "id";
private static final String UUID = "uuid";
private static final String SEX = "sex";
private static final String BIRTHDATE = "birthdate";
private static final String GIVEN_NAME = "given_name";
private static final String FAMILY_NAME = "family_name";
private static final String ASSIGNED_LOCATION = "assigned_location";
private static final String PARENT_UUID = "parent_uuid";
private static final String VOIDED = "voided";
private static Log log = LogFactory.getLog(PatientResource.class);
private static final Object createPatientLock = new Object();
private final PatientService patientService;
private final ProjectBuendiaService buendiaService;
public PatientResource() {
patientService = Context.getPatientService();
buendiaService = Context.getService(ProjectBuendiaService.class);
}
@Override public SimpleObject getAll(RequestContext context) throws ResponseException {
// #search covers a more general case of of #getAll, so we just forward through.
return search(context);
}
private SimpleObject handleSync(RequestContext context) throws ResponseException {
SyncToken syncToken = RequestUtil.mustParseSyncToken(context);
Date requestTime = new Date();
SyncPage<Patient> patients = buendiaService.getPatientsModifiedAtOrAfter(
syncToken,
syncToken != null /* includeVoided */,
MAX_PATIENTS_PER_PAGE /* maxResults */);
List<SimpleObject> jsonResults = new ArrayList<>();
for (Patient patient : patients.results) {
jsonResults.add(patientToJson(patient));
}
SyncToken newToken =
SyncTokenUtils.clampSyncTokenToBufferedRequestTime(patients.syncToken, requestTime);
// If we fetched a full page, there's probably more data available.
boolean more = patients.results.size() == MAX_PATIENTS_PER_PAGE;
return ResponseUtil.createIncrementalSyncResults(jsonResults, newToken, more);
}
// TODO: consolidate the incremental sync timestamping / wrapper logic for this and
// EncountersResource into the same class.
private SimpleObject getSimpleObjectWithResults(List<Patient> patients) {
List<SimpleObject> jsonResults = new ArrayList<>();
for (Patient patient : patients) {
jsonResults.add(patientToJson(patient));
}
SimpleObject wrapper = new SimpleObject();
wrapper.put("results", jsonResults);
return wrapper;
}
protected static SimpleObject patientToJson(Patient patient) {
SimpleObject jsonForm = new SimpleObject();
jsonForm.add(UUID, patient.getUuid());
jsonForm.add(VOIDED, patient.isPersonVoided());
if (patient.isPersonVoided()) {
// early return, we don't need the rest of the data.
return jsonForm;
}
PatientIdentifier patientIdentifier =
patient.getPatientIdentifier(DbUtil.getMsfIdentifierType());
if (patientIdentifier != null) {
jsonForm.add(ID, patientIdentifier.getIdentifier());
}
jsonForm.add(SEX, patient.getGender());
if (patient.getBirthdate() != null) {
jsonForm.add(BIRTHDATE, PATIENT_BIRTHDATE_FORMAT.format(patient.getBirthdate()));
}
String givenName = patient.getGivenName();
if (!givenName.equals(MISSING_NAME)) {
jsonForm.add(GIVEN_NAME, patient.getGivenName());
}
String familyName = patient.getFamilyName();
if (!familyName.equals(MISSING_NAME)) {
jsonForm.add(FAMILY_NAME, patient.getFamilyName());
}
// TODO: refactor so we have a single assigned location with a uuid,
// and we walk up the tree to get extra information for the patient.
String assignedLocation = DbUtil.getPersonAttributeValue(
patient, DbUtil.getAssignedLocationAttributeType());
if (assignedLocation != null) {
LocationService locationService = Context.getLocationService();
Location location = locationService.getLocation(
Integer.valueOf(assignedLocation));
if (location != null) {
SimpleObject locationJson = new SimpleObject();
locationJson.add(UUID, location.getUuid());
if (location.getParentLocation() != null) {
locationJson.add(PARENT_UUID, location.getParentLocation().getUuid());
}
jsonForm.add(ASSIGNED_LOCATION, locationJson);
}
}
return jsonForm;
}
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 String getFullName(Patient patient) {
String given = patient.getGivenName();
given = given.equals(MISSING_NAME) ? "" : given;
String family = patient.getFamilyName();
family = family.equals(MISSING_NAME) ? "" : family;
return (given + " " + family).trim();
}
private Object createInner(SimpleObject json) throws ResponseException {
// We really want this to use XForms, but let's have a simple default
// implementation for early testing
Patient patient;
synchronized (createPatientLock) {
String id = (String) json.get(ID);
if (id != null) {
List<PatientIdentifierType> identifierTypes =
Collections.singletonList(DbUtil.getMsfIdentifierType());
List<Patient> existing = patientService.getPatients(
null, id, identifierTypes, true /* exact identifier match */);
if (!existing.isEmpty()) {
Patient idMatch = existing.get(0);
String name = getFullName(idMatch);
throw new InvalidObjectDataException(
String.format(
"Another patient (%s) already has the ID \"%s\"",
name.isEmpty() ? "with no name" : "named " + name, id));
}
}
String uuid = (String) json.get(UUID);
if (uuid != null) {
Patient uuidMatch = patientService.getPatientByUuid(uuid);
if (uuidMatch != null) {
String name = getFullName(uuidMatch);
throw new InvalidObjectDataException(String.format(
"Another patient (%s) already has the UUID \"%s\"",
name.isEmpty() ? "with no name" : "named " + name, uuid));
}
}
patient = jsonToPatient(json);
patientService.savePatient(patient);
}
// Observation for first symptom date
ObservationsHandler.addEncounter(
(List) json.get("observations"), null,
patient, patient.getDateCreated(), "Initial triage",
"ADULTINITIAL", LocationResource.TRIAGE_UUID, (String) json.get("enterer_id"));
return patientToJson(patient);
}
private static String normalizeSex(String sex) {
if (sex == null) {
return "U";
} else if (sex.trim().matches("^[FfMmOoUu]")) {
return sex.trim().substring(0, 1).toUpperCase(); // F, M, O, and U are valid
} else {
return "U";
}
}
/** OpenMRS refuses to store empty names, so we use "." to represent a missing name. */
private static final String MISSING_NAME = ".";
/** Normalizes a name to something OpenMRS will accept. */
private static String normalizeName(String name) {
name = (name == null) ? "" : name.trim();
return name.isEmpty() ? MISSING_NAME : name;
}
protected static Patient jsonToPatient(SimpleObject json) {
Patient patient = new Patient();
patient.setCreator(Context.getAuthenticatedUser());
patient.setDateCreated(new Date());
if (json.containsKey(UUID)) {
patient.setUuid((String) json.get(UUID));
}
String sex = (String) json.get(SEX);
// OpenMRS calls it "gender"; we use it for physical sex (as other implementations do).
patient.setGender(normalizeSex(sex));
if (json.containsKey(BIRTHDATE)) {
patient.setBirthdate(Utils.parseLocalDate((String) json.get(BIRTHDATE), BIRTHDATE));
}
PersonName pn = new PersonName();
pn.setGivenName(normalizeName((String) json.get(GIVEN_NAME)));
pn.setFamilyName(normalizeName((String) json.get(FAMILY_NAME)));
pn.setCreator(patient.getCreator());
pn.setDateCreated(patient.getDateCreated());
patient.addName(pn);
// OpenMRS requires that every patient have a preferred identifier. If no MSF identifier
// is specified, we use a timestamp as a stand-in unique identifier.
PatientIdentifier identifier = new PatientIdentifier();
identifier.setCreator(patient.getCreator());
identifier.setDateCreated(patient.getDateCreated());
// TODO/generalize: Instead of getting the root location by a hardcoded
// name (almost certainly an inappropriate name), change the helper
// function to DbUtil.getRootLocation().
identifier.setLocation(DbUtil.getLocationByName(FACILITY_NAME, null));
if (json.containsKey(ID)) {
identifier.setIdentifier((String) json.get(ID));
identifier.setIdentifierType(DbUtil.getMsfIdentifierType());
} else {
identifier.setIdentifier("" + new Date().getTime());
identifier.setIdentifierType(DbUtil.getTimestampIdentifierType());
}
identifier.setPreferred(true);
patient.addIdentifier(identifier);
// Set assigned location last, as doing so saves the patient, which could fail
// if performed in the middle of patient creation.
if (json.containsKey(ASSIGNED_LOCATION)) {
String assignedLocationUuid = null;
Object assignedLocation = json.get(ASSIGNED_LOCATION);
if (assignedLocation instanceof String) {
assignedLocationUuid = (String) assignedLocation;
}
if (assignedLocation instanceof Map) {
assignedLocationUuid = (String) ((Map) assignedLocation).get(UUID);
}
if (assignedLocationUuid != null) {
setLocation(patient, assignedLocationUuid);
}
}
return patient;
}
private static void setLocation(Patient patient, String locationUuid) {
// Apply the given assigned location to a patient, if locationUuid is not null.
if (locationUuid == null) return;
Location location = Context.getLocationService().getLocationByUuid(locationUuid);
if (location != null) {
DbUtil.setPersonAttributeValue(patient,
DbUtil.getAssignedLocationAttributeType(),
Integer.toString(location.getId()));
}
}
@Override public String getUri(Object instance) {
Patient patient = (Patient) instance;
Resource res = getClass().getAnnotation(Resource.class);
return RestConstants.URI_PREFIX + res.name() + "/" + patient.getUuid();
}
@Override public SimpleObject search(RequestContext context) throws ResponseException {
// If there's an ID, run an actual search, otherwise, run a sync.
String patientId = context.getParameter("id");
if (patientId != null) {
try {
logger.request(context, this, "search");
SimpleObject result = searchInner(patientId);
logger.reply(context, this, "search", result);
return result;
}
catch (Exception e) {
logger.error(context, this, "search", e);
throw e;
}
} else {
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;
}
}
}
private SimpleObject searchInner(String patientId) throws ResponseException {
List<PatientIdentifierType> idTypes =
Collections.singletonList(DbUtil.getMsfIdentifierType());
List<Patient> patients = patientService.getPatients(null, patientId, idTypes, false);
return getSimpleObjectWithResults(patients);
}
@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 {
Patient patient = patientService.getPatientByUuid(uuid);
if (patient == null) {
throw new ObjectNotFoundException();
}
return patientToJson(patient);
}
@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;
}
}
/**
* Receives a SimpleObject that is parsed from the Gson serialization of a client-side
* Patient bean. It has the following semantics:
* <ul>
* <li>Any field 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>Subfields of location and age are not merged; instead the whole item is replaced
* <li>If the client requests a change that is illegal, that is an error. Really the
* whole call should fail, but for now there may be partial updates
* </ul>
*/
private Object updateInner(String uuid, SimpleObject simpleObject) throws ResponseException {
Patient patient = patientService.getPatientByUuid(uuid);
if (patient == null) {
throw new ObjectNotFoundException();
}
applyEdits(patient, simpleObject);
return patientToJson(patient);
}
/** Applies edits to a Patient. Returns true if any changes were made. */
protected void applyEdits(Patient patient, SimpleObject edits) {
boolean changedPatient = false;
String newGivenName = null;
String newFamilyName = null;
String newId = null;
for (Map.Entry<String, Object> entry : edits.entrySet()) {
switch (entry.getKey()) {
// ==== JSON keys that update attributes of the Patient entity.
case FAMILY_NAME:
newFamilyName = (String) entry.getValue();
break;
case GIVEN_NAME:
newGivenName = (String) entry.getValue();
break;
case ASSIGNED_LOCATION:
Map assignedLocation = (Map) entry.getValue();
setLocation(patient, (String) assignedLocation.get(UUID));
break;
case BIRTHDATE:
patient.setBirthdate(Utils.parseLocalDate((String) entry.getValue(), BIRTHDATE));
changedPatient = true;
break;
case ID:
newId = (String) entry.getValue();
break;
default:
log.warn("Property is nonexistent or not updatable; ignoring: " + entry);
break;
}
}
PatientIdentifier identifier = patient.getPatientIdentifier(DbUtil.getMsfIdentifierType());
if (newId != null && (identifier == null || !newId.equals(identifier.getIdentifier()))) {
synchronized (createPatientLock) {
List<PatientIdentifierType> identifierTypes =
Collections.singletonList(DbUtil.getMsfIdentifierType());
List<Patient> existing = patientService.getPatients(
null, newId, identifierTypes, true /* exact identifier match */);
if (!existing.isEmpty()) {
Patient idMatch = existing.get(0);
String name = getFullName(idMatch);
throw new InvalidObjectDataException(
String.format(
"Another patient (%s) already has the ID \"%s\"",
name.isEmpty() ? "with no name" : "named " + name, newId));
}
if (identifier != null) {
patient.removeIdentifier(identifier);
}
identifier = new PatientIdentifier();
identifier.setCreator(patient.getCreator());
identifier.setDateCreated(patient.getDateCreated());
// TODO/generalize: Instead of getting the root location by a hardcoded
// name (almost certainly an inappropriate name), change the helper
// function to DbUtil.getRootLocation().
identifier.setLocation(DbUtil.getLocationByName(FACILITY_NAME, null));
identifier.setIdentifier(newId);
identifier.setIdentifierType(DbUtil.getMsfIdentifierType());
identifier.setPreferred(true);
patient.addIdentifier(identifier);
patientService.savePatient(patient);
}
changedPatient = true;
}
if (newGivenName != null || newFamilyName != null) {
PersonName oldName = patient.getPersonName();
if (!normalizeName(newGivenName).equals(oldName.getGivenName())
|| !normalizeName(newFamilyName).equals(oldName.getFamilyName())) {
PersonName newName = new PersonName();
newName.setGivenName(
newGivenName != null ? normalizeName(newGivenName) : oldName.getGivenName());
newName.setFamilyName(
newFamilyName != null ? normalizeName(newFamilyName) : oldName.getFamilyName());
patient.addName(newName);
oldName.setVoided(true);
changedPatient = true;
}
}
if (changedPatient) {
patientService.savePatient(patient);
}
}
}