Complex Obs Support


Contents

Introduction

This page describes the changes to OpenMRS to support Complex Observations. See ticket:656 and ticket:107.
Complex Obs Support gives OpenMRS the ability to store observations (Obs) on a patient with complex values such as an x-ray image, a document, or a media file. These complex data are stored on the file system outside of the OpenMRS database. In order to do this, a few things are necessary:

  • The Concept Dictionary must have at least one Concept with Datatype as Complex.
  • The ConceptComplex must be associated with a registered ComplexObsHandler
  • The global property obs.complex_obs_dir must be set.
  • The Obs Obs.getConcept() must return a ConceptComplex (a Concept with Datatype="Complex").

Intro to ComplexObsHandler

  • ComplexObsHandler classes are hidden from the user and only used by the ObsService.
  • They do the internal work of saving and retrieving ComplexData from a ComplexObs.
  • Each ComplexObsHandler is mapped in the Spring framework with a unique key.
  • ComplexObsHandlers can be registered in any of 3 places:
    • API level handlers: metadata/api/spring/applicationContextService.xml
    • Web layer handlers: web/WEB-INF/openmrs-servlet.xml
    • Module loaded handlers: metadata/moduleApplicationContext.xml
  • If two handlers are mapped to the same key, the last one loaded by Spring will override the first.
  • Web layer handlers and Module handlers should extend the API level handlers.

Workflow

This workflow can also be found in the JUnit test class test.api.org.openmrs.test.api.ComplexObsTest.java

Set Up

  • Create a new ConceptDatatype and call it "Complex" (if it does not already exist).
  • Set a global property called obs.complex_obs_dir to the folder name or path where to store Complex Obs. The default is "complex_obs" folder in the Application Data directory.
  • Create a new Concept in the Concept Dictionary and set the Datatype to "Complex", and give it a handler from the list of registered handlers.
  • Send me an e-mail to let me know that someone actually read this far into the page.

Get a ConceptComplex

Here, 1867 is the conceptId for the ConceptComplex created in #Set Up.

ConceptComplex conceptComplex = Context.getConceptService().getConceptComplex(Integer.valueOf(1867));

Create an Obs

Here patientId 48609 is a test patient.

Obs obs = new Obs();
// Set the required properties.
obs.setPerson(Context.getPatientService().getPatient(Integer.valueOf(48609)));
obs.setCreator(Context.getAuthenticatedUser());
obs.setDateCreated(new Date());
obs.setObsDatetime(new Date());
Location location = Context.getEncounterService().getLocation(Integer.valueOf(1));
obs.setLocation(location);
obs.setVoided(false);
// Set the ConceptComplex
obs.setConcept(conceptComplex);

Get an Image

BufferedImage img = null;
try {
    img = ImageIO.read(new File("/home/bmckown/Desktop/test/logo.png"));
} catch (IOException e) {
    System.out.println(e);
}

Create a ComplexData Object

ComplexData cdat = new ComplexData("test-image.jpg", img);

Set the Obs.complexData

obs.setComplexData(cdat);

Save the Obs to the database.

// Use this in complex_obs branch:
Context.getObsService().createObs((Obs) obs);
// Use this as of OpenMRS Release 1.3:
Context.getObsService().saveObs((Obs) obs);

Get the ComplexObs from the Database

Integer obsId = obs.getObsId();
Obs cobs = Context.getObsService().getComplexObs(obsId, null);

Get the ComplexObs from the Database

Integer obsId = obs.getObsId();
Obs cobs = Context.getObsService().getComplexObs(obsId, null);

Save the ComplexData to the Desktop

File outputfile = new File("/home/bmckown/Desktop", cobs.getComplexData().getTitle());
try {
  BufferedImage outImg = (BufferedImage) cobs.getComplexData().getData();
  ImageIO.write(outImg, "jpg", outputfile);
} catch (IOException ioe) {
  System.out.println(ioe);
}

How to Create a ComplexObsHandler

Requirements

  • A ComplexObsHandler must implement the ComplexObsHandler interface
  • A ComplexObsHandler may extend one of the API layer ComplexObsHandlers
  • The ComlexObsHandler must be registered by Spring with a key.

Example

WebImageHandler

  • Extends org.openmrs.obs.handler.ImageHandler
  • Uses ImageHandler to saveObs().
  • Overrides the getComplexData() method to provide web-specific ComplexData
    • Provides hyperlink to ComplexObsServlet instead of the heavyweight data.
public class WebImageHandler extends ImageHandler {
  public ComplexData getComplexData(Obs obs, String view) {
     if (Webutils.HYPERLINK_VIEW.equals(view)) {
         String link = "/ComplexObsServlet?obsId=" + obs.getObsId();
         return link;
     }
     return super.getComplexObs(obs, view);
  }
}

Register WebImageHandler

  • Register WebImageHandler in openmrs-servlet.xml
  • Or alternatively, for a module register in ModuleApplicationContext.xml
<bean parent="obsServiceTarget" >
        <property name="handlers">
            <map>
                <entry>
                    <key><value>ImageHandler</value></key>
                    <bean class="org.openmrs.web.controller.observation.handler.WebImageHandler"/>
                </entry>
            </map>
        </property>
    </bean>

Complex Obs Code

Copyright Notice

The contents of this page are subject to the OpenMRS Public License
Version 1.0 (the "License"); you may not use this file except in
compliance with the License. You may obtain a copy of the License at
license.openmrs.org

Software distributed under the License is distributed on an "AS IS"
basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
License for the specific language governing rights and limitations
under the License.

Copyright (C) OpenMRS, LLC. All Rights Reserved.

Complex Obs

/**
 * A Complex Obs is not a database table or a Java class. 
 * A Complex Obs is really just an Obs where Obs.Concept is a ConceptComplex 
 *   and where the Obs has the ability to fetch and retrieve a ComplexData Object 
 *   from the file system.  
 * An Obs is considered complex when both Obs.getValueComplex() is not null 
 *    and Obs.getConcept() is a ConceptComplex.
 *
 * NOTE: Only Complex Obs related properties and methods are shown here.
 */
public class Obs implements java.io.Serializable {
 
	// persisted property
	protected String valueComplex;
	// transient property
	protected ComplexData complexData;
 
	public boolean isComplex() {
		if (this.getValueComplex() != null) {
			return true;
		}
		return false;
	}
 
	public boolean isComplexObs( ) {
		return isComplex();
	}
	/**
 	 * To be used only by the ComplexObsHandler
 	 */ 
	public String getValueComplex() {
		return this.valueComplex;
	}
 
	/**
	 * To be used only by the ComplexObsHandler
	 */
	public void setValueComplex(String valueComplex) {
		this.valueComplex = valueComplex;
	}
 
	/**
	 * Sets the ComplexData to be stored in the file system.
	 */
	public void setComplexData(ComplexData complexData) {
		this.complexData = complexData;
	}
 
	/**
	 * Get the ComplexData that is stored in the file system for this Obs.
	 */ 
	public ComplexData getComplexData() {
		return this.complexData;
	}
 
	/**
 	 * Example: 
 	 * Obs.valueComplex: "JPG image |example_x-ray.jpg"
 	 * Obs.getValueAsString() returns "JPG image"
 	 */ 	
	public String getValueAsString(Locale locale) {
		// returns the title portion of the valueComplex
		// which is everything before the first bar '|' character.
		if (getValueComplex() != null) {
			String[] valueComplex = getValueComplex().split("\\|");
			for (int i=0; i<valueComplex.length ; i++) {
				if (!"".equals(valueComplex[i])) {
					return valueComplex[i].trim();
				}
			}
		}
		return "";
	}
}

Database Table OBS

Only ComplexObs related attributes are shown here.
Column Name Type Default Description
Primary Key obs_id int org.openmrs.Obs
concept_id concept.concept_id int org.openmrs.Concept
value_complex varchar java.lang.String
Primary Key primary key, Required required

ComplexData

/**
 * ComplexData is a transient Object that is not itself persisted
 * in the database. It has a data Object and a title. Alternatively, 
 * it can have a byte array instead of an Object.
 */
public class ComplexData implements java.io.Serializable {
	private Object data;
	private byte[] bytes;
	private String title;
	public static enum Type {
		OBJECT, BYTE
	}
	private Type type;
 
	/**
	 * Default constructor 
	 */
	public ComplexData(String title, Object data) {
		setTitle(title);
		setData(data);
		this.type = Type.OBJECT;
	}
 
	/**
	 * Alternative constructor 
	 */
	public ComplexData(String title, byte[] bytes) {
		setBytes(bytes);
		setTitle(title);
		this.type = Type.BYTE;
	}
 
	public String getTitle() {
		return this.title;
	}
 
	public Object getData() {
		if (this.type != Type.OBJECT) {
			return getBytes();
		}
		return this.data;
	}
 
	public byte[] getBytes() {
		if (this.type != Type.BYTE && null != data) {
			return data.toString().getBytes();
		}
		return this.bytes;
	}
 
	public ComplexData.Type getType() {
		return this.type;
	}
}

ConceptComplex

/**
 * Child class of Concept that has a ComplexObsHandler associated with the
 * Concept.
 */
public class ConceptComplex extends Concept implements Serializable {
 
	private String handler;
 
	public ConceptComplex() { }
 
	/**
	 * Overrides parent method and returns true.
	 */
	public boolean isComplex() {
		return true;
	}
 
	/**
	 * Set the Spring mapped key to the ComplexObsHandler. 
	 */
	public void setHandler(String handler) {
		this.handler = handler;
	}
 
	/**
	 * Get the key to the ComplexObsHandler associated with this ConceptComplex.
	 */
	public String getHandler() {
		return this.handler;
	}
}

Hibernate mapping: Concept.hbm.xml

<hibernate-mapping package="org.openmrs">
	<class name="Concept" table="concept" batch-size="25">
	   <id name="conceptId" type="java.lang.Integer" column="concept_id"> <generator class="assigned" /> </id>
	   <joined-subclass name="org.openmrs.ConceptComplex" table="concept_complex" extends="org.openmrs.Concept" lazy="false">
	        <key column="concept_id" not-null="true" on-delete="cascade" />
	        <property name="handler" type="java.lang.String" column="handler" length="255"/>
	    </joined-subclass>	
	</class>
</hibernate-mapping>

Database Table: CONCEPT_COMPLEX

Column Name Type Default Description
Primary Key concept_id concept.concept_id int org.openmrs.Concept
handler_id varchar java.lang.String
Primary Key primary key, Required required

Concept Service

/**
 * NOTE: This is only showing code related to ConceptComplex
 */
public class ConceptServiceImpl implements ConceptService {
 
	/**
	 * Save a ConceptComplex 
	 */
	public void saveConcept(ConceptComplex concept) {
			getConceptDAO().saveConcept(concept);		
	}
 
	/**
	 * Get a ConceptComplex 
	 */
	public ConceptComplex getConceptComplex(Integer conceptId) {
			return getConceptDAO().getConceptComplex(conceptId);
	}
}

Obs Service

/**
 * The ObsService has a Map of registered ComplexObsHandlers that are available to use.  
 * These are registered by Spring at startup.  The Spring mapping can be located in either 
 * of 3 files:
 * For API level handlers: metadata/api/spring/applicationContextService.xml
 * For Web layer handlers: web/WEB-INF/openmrs-servlet.xml
 * For Module loaded handlers: metadata/moduleApplicationContext.xml
 */
public class ObsServiceImpl implements ObsService {
 
	private static Map<String, ComplexObsHandler> handlers = null;
 
	/**
	 * This will become saveObs method in openmrs-1.3.0 
	 */
	public void createObs(Obs obs) throws APIException {
		ComplexObsHandler handler = getHandler(obs);
		if (null != handler) {
			handler.saveObs(obs);
		}
		setRequiredObsProperties(obs);
		getObsDAO().createObs(obs);
	}
 
	/**
	 * 
	 */
	public Obs getComplexObs(Integer obsId, String view) throws APIException {
		Obs obs = getObsDAO().getObs(obsId);
		if (obs.isComplex()) {
			return getHandler(obs).getObs(obs, view);
		}
		return obs;
	}
 
	/**
	 * 
	 */
	public ComplexData getComplexData(Integer obsId, String view)
	        throws APIException {
		return this.getComplexObs(obsId, view).getComplexData();
	}
 
	/**
	 *
	 */
	public ComplexData getComplexData(Obs obs, String view) throws APIException {
		return this.getComplexObs(obs.getObsId(), view).getComplexData();
	}
 
	/**
	 * 
	 */
	public boolean purgeComplexData(Obs obs) throws APIException {
		if (obs.isComplex()) {
			ComplexObsHandler handler = this.getHandler(obs);
			if (null != handler) {
				return handler.purgeComplexData(obs);
			}
		}
		return false;
	}
 
	/**
	 * This will be put in saveObs() method in openmrs-1.3.0 
	 */
	public void updateObs(Obs obs) throws APIException {
		if (obs.isComplex() && null != obs.getComplexData().getData()) {
			ComplexObsHandler handler = getHandler(obs);
			if (null != handler) {
				handler.saveObs(obs);
			}
		}
		if (obs.isVoided() && obs.getVoidedBy() == null)
			voidObs(obs, obs.getVoidReason());
		else if (obs.isVoided() == false && obs.getVoidedBy() != null)
			unvoidObs(obs);
		else {
			setRequiredObsProperties(obs);
			log.debug("Date voided: " + obs.getDateVoided());
			getObsDAO().updateObs(obs);
		}
	}
 
	/**
	 * Convenience method to get the ComplexObsHandler associated with a complex
	 * Obs. Returns the ComplexObsHandler. Returns null if the
	 * Obs.isComplexObs() is false or there is an error instantiating the
	 * handler class.
	 * 
	 * @param obs A complex Obs.
	 * @return ComplexObsHandler for the complex Obs. or null on error.
	 */
	public ComplexObsHandler getHandler(Obs obs) {
		if (obs.getConcept().isComplex()) {
			// Get the ConceptComplex from the ConceptService then return its
			// handler.
			return this.getHandler(Context.getConceptService().getConceptComplex(obs.getConcept().getConceptId()).getHandler());
		}
		return null;
	}
 
	/**
	 * 
	 */
	public ComplexObsHandler getHandler(Class<? extends ComplexObsHandler> clazz) {
		try {
			return handlers.get(clazz);
		} catch (Exception ex) {
			log.error("Failed to get report renderer for " + clazz, ex);
			return null;
		}
	}
 
	/**
	 * Get all ComplexObsHandlers 
	 */ 	
	public List<ComplexObsHandler> getComplexObsHandlers() {
		return new ArrayList<ComplexObsHandler>(getHandlers().values());
	}
 
	/**
	 * Get a ComplexObsHandler by key
	 */
	public ComplexObsHandler getHandler(String key) {
		try {
			return handlers.get(key);
		} catch (Exception ex) {
			log.error("Failed to get ComplexObsHandler for "
			        + this.getHandler(key).getClass().getCanonicalName(), ex);
			return null;
		}
	}
 
	/**
	 * Sets the ComplexObsHandlers
	 */
	public void setHandlers(Map<String, ComplexObsHandler> newHandlers)
	        throws APIException {
		for (Map.Entry<String, ComplexObsHandler> entry : newHandlers.entrySet()) {
			registerHandler(entry.getKey(), entry.getValue());
		}
	}
 
	/**
	 * Gets the Map of ComplexObsHandlers
	 */
	public Map<String, ComplexObsHandler> getHandlers() throws APIException {
		if (handlers == null)
			handlers = new LinkedHashMap<String, ComplexObsHandler>();
 
		return handlers;
	}
 
	public void registerHandler(String key, ComplexObsHandler handler)
	        throws APIException {
		getHandlers().put(key, handler);
	}
 
	public void registerHandler(String key, String handlerClass)
	        throws APIException {
		try {
			Class loadedClass = OpenmrsClassLoader.getInstance()
			                                      .loadClass(handlerClass);
			registerHandler(key, (ComplexObsHandler) loadedClass.newInstance());
 
		} catch (Exception e) {
			throw new APIException("Unable to load and instantiate handler", e);
		}
	}
 
	public void removeHandler(String key) {
		handlers.remove(key);
	}
}

ComplexObsHandler

/**
 * Interface for handling complex obs. Implementing classes are responsible for
 * the storage and retrieval of ComplexData associated with an Obs that is
 * complex - where Obs.isComplex() returns true.
 */
public interface ComplexObsHandler {
	/**
	 * Save a complex obs. This extracts the ComplexData from an Obs, stores it
	 * to a location determined by the hander, and returns the Obs with the
	 * ComplexData nullified.
	 */
	public Obs saveObs(Obs obs);
 
	/**
	 * Fetches the ComplexData from the location indicated from
	 * Obs.value_complex, attaches the ComplexData onto the Obs and returns the
	 * Obs
	 */
	public Obs getObs(Obs obs);
 
	/**
	 * Fetches the ComplexData from the location indicated from
	 * Obs.value_complex, attaches ComplexData onto the Obs and returns the Obs.
	 * 
	 * The ComplexData is returned in the format specified by the view.
	 * Available views at the API level are found in: org.openmrs.util.OpenmrsConstants
	 */
	public Obs getObs(Obs obs, String view);
 
	/**
	 * Fetches the ComplexData from the location indicated from
	 * Obs.value_complex.
	 * 
	 * The ComplexData is returned in the format specified by the view.
	 * Available views at the API level are found in: org.openmrs.util.OpenmrsConstants
	 */
	public ComplexData getComplexData(Obs obs, String view);
 
	/**
	 * Completely removes the ComplexData Object from its storage location.
	 * 
	 * TODO: If we cannot delete the complex data object because of an error, do
	 * we want to return the Obs, a boolean false, or an Exception?
	 */
	public boolean purgeComplexData(Obs obs);
}

ImageHandler

/**
 * Handler for storing basic images for complex obs to the file system. The
 * image mime type used is taken from the image name. if the .* image name
 * suffix matches {@link javax.imageio.ImageIO#getWriterFormatNames()} then that
 * mime type will be used to save the image. Otherwise the default mime type is
 * ImageHandler.MIME (currently "jpg").
 * 
 * Images are stored in the location specified by the global property:
 * "obs.complex_obs_dir"  
 */
public class ImageHandler implements ComplexObsHandler {
 
	private NumberFormat nf;
	SimpleDateFormat longfmt;
	// default mime type
	public static final String MIME = "jpg";
	private Set<String> mimes;
 
	/**
	 * Constructor initializes formats for alternative file names to protect
	 * from unintentionally overwriting existing files.
	 */
	public ImageHandler() {
		nf = NumberFormat.getInstance();
		nf.setMaximumFractionDigits(0);
		nf.setMinimumIntegerDigits(2);
		longfmt = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
		// Create a HashSet to quickly check for supported mime types.
		mimes = new HashSet<String>();
		for (String mt : ImageIO.getWriterFormatNames()) {
			mimes.add(mt);
		}
	}
 
	/**
	 * Currently supports all views and is the same as {@link #getObs(Obs)}
	 */
	public Obs getObs(Obs obs, String view) {
		return this.getObs(obs);
	}
 
	/**
	 * Extracts the ComplexData from the Obs, writes it to the file system, and removes the ComplexData from the Obs Object.
	 * When creating a new Obs, if a file already exists by the given name, a number is appended to it. 
	 * If no name has been given, the current date and time is used.
	 */
	public Obs saveObs(Obs obs) {
		// Get the buffered image from the ComplexData.
		BufferedImage img = (BufferedImage) obs.getComplexData().getData();
		if (img == null) {
			log.error("Cannot save complex obs where obsId=" + obs.getObsId()
			        + " because its ComplexData.getData() is null.");
			return obs;
		}
 
		// Get the title and remove the mime type.
		String t = obs.getComplexData().getTitle();
		String[] titles = t.split("\\.");
		System.out.println(titles.length);
		String mime = (titles.length < 2) ? titles[0]
		        : titles[titles.length - 1];
		String MIME = mimes.contains(mime) ? mime : ImageHandler.MIME;
		String title = obs.getComplexData().getTitle().replace("." + MIME, "");
 
		File dir = OpenmrsUtil.getDirectoryInApplicationDataDirectory(Context.getAdministrationService()
		                                                                     .getGlobalProperty("obs.complex_obs_dir"));
		File outputfile = null;
 
		// Write the image to the file system.
		try {
			if (null == title) {
				String now = longfmt.format(new Date());
				outputfile = new File(dir, now);
			} else {
				outputfile = new File(dir, title + "." + MIME);
				// outputfile = new File(dir, title);
			}
			int i = 0;
			String tmp = null;
			// If the Obs does not exist, but the File does, append a two-digit
			// count number to the filename and save it.
			while (obs.getObsId() == null && outputfile.exists() && i < 100) {
				tmp = null;
				// Remove the mime type from the filename.
				tmp = new String(outputfile.getAbsolutePath()
				                           .replace("." + MIME, ""));
				outputfile = null;
				// Append two-digit count number to the filename.
				String filename = (i < 1) ? tmp + "_"
				        + nf.format(Integer.valueOf(++i))
				        : tmp.replace(nf.format(Integer.valueOf(i)),
				                      nf.format(Integer.valueOf(++i)));
				// Append the mime type to the filename.
				// outputfile = new File(filename);
				outputfile = new File(filename + "." + MIME);
			}
			// Write the file to the file system.
			// ImageIO.write(img, MIME, outputfile);
			ImageIO.write(img, MIME, outputfile);
		} catch (IOException ioe) {
			log.error("Trying to write complex obs to the file system. ", ioe);
		}
 
		// Set the Title and URI for the valueComplex
		obs.setValueComplex(MIME + " image |" + outputfile.getName());
 
		// Remove the ComlexData from the Obs
		obs.setComplexData(null);
 
		return obs;
	}
 
	/**
	 * Retrieves the image from the file system, creates a new ComplexData Object with the image,
	 * and attaches it to the given Obs.
	 */  
	public Obs getObs(Obs obs) {
		File file = ImageHandler.getComplexDataFile(obs);
		BufferedImage img = null;
		try {
			img = ImageIO.read(file);
		} catch (IOException e) {
			System.out.println("Trying to read file: " + file.getAbsolutePath() + " " + e);
		}
 
		ComplexData complexData = new ComplexData(file.getName(), img);
 
		obs.setComplexData(complexData);
 
		return obs;
	}
 
	/**
	 * Removes the image from the file system.
	 * Also removes the ComplexData Object from the Obs.
	 * Does NOT change the Obs.valueComplex 
	 */
	public boolean purgeComplexData(Obs obs) {
		File file = ImageHandler.getComplexDataFile(obs);
		if (file.exists() && file.delete()) {
			obs.setComplexData(null);
			// obs.setValueComplex(null);
			return true;
		}
		log.debug("Could not delete complex data object for obsId=" + obs.getObsId() + " located at " + file.getAbsolutePath());
		return false;
	}
 
	/**
	 * Same as getObs(Obs obs) except that it returns only the ComplexData Object 
	 */
	public ComplexData getComplexData(Obs obs, String view) {
		return this.getObs(obs).getComplexData();
	}
 
	/**
	 * Convenience method to create and return a file for the stored
	 * ComplexData.data Object
	 */
	public static File getComplexDataFile(Obs obs) {
		String[] names = obs.getValueComplex().split("\\|");
		String filename = names.length < 2 ? names[0] : names[names.length - 1];
		File dir = OpenmrsUtil.getDirectoryInApplicationDataDirectory(Context.getAdministrationService().getGlobalProperty("obs.complex_obs_dir"));
		return new File(dir, filename);
	}
}

ImageHandler mapping in applicationContext-Service.xml

<bean id="obsServiceTarget" class="org.openmrs.api.impl.ObsServiceImpl">
		<property name="obsDAO"><ref bean="obsDAO"/></property>	
        <property name="handlers">
            <map>
                <entry>
                    <key><value>ImageHandler</value></key>
                    <bean class="org.openmrs.obs.handler.ImageHandler"/>
                </entry>
                <entry>