Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions api/src/main/java/org/openmrs/module/pihapps/PihAppsService.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@
import org.openmrs.Location;
import org.openmrs.Obs;
import org.openmrs.Order;
import org.openmrs.annotation.Authorized;
import org.openmrs.api.OpenmrsService;
import org.openmrs.module.pihapps.encounter.EncounterSearchCriteria;
import org.openmrs.module.pihapps.encounter.EncounterSearchResult;
import org.openmrs.module.pihapps.obs.ObsSearchCriteria;
import org.openmrs.module.pihapps.obs.ObsSearchResult;
import org.openmrs.module.pihapps.orders.EncounterFulfillingOrders;
import org.openmrs.module.pihapps.orders.OrderSearchCriteria;
import org.openmrs.module.pihapps.orders.OrderSearchResult;
import org.openmrs.module.pihapps.orders.PatientWithOrdersSearchResult;
import org.openmrs.util.PrivilegeConstants;

import java.util.List;
import java.util.Map;
Expand All @@ -51,5 +55,47 @@ public interface PihAppsService extends OpenmrsService {

void revertOrdersToOrdered(List<Order> orders);

/**
* Searches observations by whatever {@link ObsSearchCriteria} names: the patient, the concepts,
* the users in their audit trail, and a date range over either the observation's own datetime or
* the audit action. Every filter narrows, and a criteria naming none of them matches every
* observation, so a caller that means to search rather than to list is responsible for
* narrowing it.
*
* <p>Voided observations are left out unless the criteria ask for them. An audit does ask: they
* are the whole point of a voidedBy search, and what an auditor looking at what a user created
* most wants to see.
*
* <p>Ordering is the caller's to set, and paging without one is not deterministic. An audit
* wants the most recent audit action first, which means ordering by the column belonging to the
* action it named rather than by the observation's own datetime, with the obs id breaking ties
* so that paging cannot repeat or skip a row.
*
* @param searchCriteria what to search for, how to page it and how to sort it
* @return the matching observations and how many there are in total
*/
@Authorized(PrivilegeConstants.GET_OBS)
ObsSearchResult getObs(ObsSearchCriteria searchCriteria);

/**
* Searches encounters by whatever {@link EncounterSearchCriteria} names: the users in their
* audit trail, the provider recorded on them, their type, and a date range. Every filter
* narrows, and a criteria naming none of them matches every encounter, so a caller that means
* to search rather than to list is responsible for narrowing it.
*
* <p>Voided encounters are left out unless the criteria ask for them. An audit does ask: they
* are the whole point of a voidedBy search, and what an auditor looking at what a user entered
* most wants to see.
*
* <p>Ordering is the caller's to set, as it is on {@link #getObs(ObsSearchCriteria)},
* and paging without one is not deterministic. An audit wants the most recent audit action
* first, which means ordering by the column belonging to the action it named — or by the
* encounter's own datetime where only a provider was named — with the encounter id breaking
* ties so that paging cannot repeat or skip a row.
*
* @param searchCriteria what to search for, how to page it and how to sort it
* @return the matching encounters and how many there are in total
*/
@Authorized(PrivilegeConstants.GET_ENCOUNTERS)
EncounterSearchResult getEncounters(EncounterSearchCriteria searchCriteria);
}
113 changes: 111 additions & 2 deletions api/src/main/java/org/openmrs/module/pihapps/PihAppsServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,20 @@
import org.hibernate.Criteria;
import org.hibernate.FlushMode;
import org.hibernate.criterion.Criterion;
import org.hibernate.criterion.DetachedCriteria;
import org.hibernate.criterion.Projections;
import org.hibernate.criterion.Subqueries;
import org.openmrs.Concept;
import org.openmrs.Encounter;
import org.openmrs.EncounterProvider;
import org.openmrs.EncounterType;
import org.openmrs.Location;
import org.openmrs.LocationTag;
import org.openmrs.Obs;
import org.openmrs.Order;
import org.openmrs.Patient;
import org.openmrs.Provider;
import org.openmrs.User;
import org.openmrs.annotation.Authorized;
import org.openmrs.api.EncounterService;
import org.openmrs.api.LocationService;
Expand All @@ -37,6 +43,8 @@
import org.openmrs.api.db.hibernate.DbSessionFactory;
import org.openmrs.api.impl.BaseOpenmrsService;
import org.openmrs.module.emrapi.EmrApiConstants;
import org.openmrs.module.pihapps.encounter.EncounterSearchCriteria;
import org.openmrs.module.pihapps.encounter.EncounterSearchResult;
import org.openmrs.module.pihapps.obs.ObsSearchCriteria;
import org.openmrs.module.pihapps.obs.ObsSearchResult;
import org.openmrs.module.pihapps.orders.EncounterFulfillingOrders;
Expand Down Expand Up @@ -576,9 +584,10 @@ public void revertOrdersToOrdered(List<Order> orders) {
}
}


@Override
@Transactional(readOnly = true)
@Authorized(PrivilegeConstants.GET_PATIENTS)
@Authorized(PrivilegeConstants.GET_OBS)
@SuppressWarnings({ "unchecked" })
public ObsSearchResult getObs(ObsSearchCriteria searchCriteria) {
ObsSearchResult result = new ObsSearchResult();
Expand All @@ -605,7 +614,18 @@ public ObsSearchResult getObs(ObsSearchCriteria searchCriteria) {
@SuppressWarnings({ "deprecation" })
private Criteria createHibernateObsSearchCriteria(ObsSearchCriteria searchCriteria, boolean applySortCriteria) {
Criteria c = sessionFactory.getHibernateSessionFactory().getCurrentSession().createCriteria(Obs.class);
c.add(eq("voided", false));
Comment thread
mseaton marked this conversation as resolved.
if (!searchCriteria.isIncludeVoided()) {
c.add(eq("voided", false));
Comment thread
mseaton marked this conversation as resolved.
}
if (searchCriteria.getCreatedBy() != null) {
c.add(eq("creator", searchCriteria.getCreatedBy()));
}
if (searchCriteria.getVoidedBy() != null) {
c.add(eq("voidedBy", searchCriteria.getVoidedBy()));
}
// Each range names the column it bounds, so none of this depends on which filters are set.
addDateBounds(c, "dateCreated", searchCriteria.getCreatedOnOrAfter(), searchCriteria.getCreatedOnOrBefore());
addDateBounds(c, "dateVoided", searchCriteria.getVoidedOnOrAfter(), searchCriteria.getVoidedOnOrBefore());
if (searchCriteria.getPatient() != null) {
c.add(eq("person", searchCriteria.getPatient()));
}
Expand Down Expand Up @@ -634,4 +654,93 @@ private Criteria createHibernateObsSearchCriteria(ObsSearchCriteria searchCriter
}
return c;
}

@Override
@Transactional(readOnly = true)
@Authorized(PrivilegeConstants.GET_ENCOUNTERS)
@SuppressWarnings({ "unchecked" })
public EncounterSearchResult getEncounters(EncounterSearchCriteria searchCriteria) {
EncounterSearchResult result = new EncounterSearchResult();
// First query to get total count
Criteria c = createHibernateEncounterSearchCriteria(searchCriteria, false);
c.setProjection(Projections.rowCount());
Long totalCount = (Long) c.list().get(0);
result.setTotalCount(totalCount);
// Then query to get page of results
c = createHibernateEncounterSearchCriteria(searchCriteria, true);
c.setProjection(null);
Integer startIndex = searchCriteria.getStartIndex();
Integer limit = searchCriteria.getLimit();
if (limit != null) {
startIndex = startIndex == null ? 0 : startIndex;
c.setFirstResult(startIndex);
c.setMaxResults(limit);
}
result.setEncounters(c.list());
return result;
}

/**
* Both ends run inclusively. The upper end is widened to the end of its day when it carries no
* time, so that a range named in days covers the whole of the last one.
*/
private void addDateBounds(Criteria c, String property, Date onOrAfter, Date onOrBefore) {
if (onOrAfter != null) {
// No adjustment: midnight is already the first moment of its day.
c.add(ge(property, onOrAfter));
}
Date upperBound = PihAppsUtils.getEndOfDayIfTimeExcluded(onOrBefore);
if (upperBound != null) {
c.add(le(property, upperBound));
}
}

@SuppressWarnings({ "deprecation" })
private Criteria createHibernateEncounterSearchCriteria(EncounterSearchCriteria searchCriteria,
boolean applySortCriteria) {
Criteria c = sessionFactory.getHibernateSessionFactory().getCurrentSession().createCriteria(Encounter.class);
if (!searchCriteria.isIncludeVoided()) {
c.add(eq("voided", false));
}
if (searchCriteria.getEncounterType() != null) {
c.add(eq("encounterType", searchCriteria.getEncounterType()));
}
if (searchCriteria.getCreatedBy() != null) {
c.add(eq("creator", searchCriteria.getCreatedBy()));
}
if (searchCriteria.getChangedBy() != null) {
c.add(eq("changedBy", searchCriteria.getChangedBy()));
}
if (searchCriteria.getVoidedBy() != null) {
c.add(eq("voidedBy", searchCriteria.getVoidedBy()));
}
// Each range names the column it bounds, so none of this depends on which filters are set.
addDateBounds(c, "dateCreated", searchCriteria.getCreatedOnOrAfter(), searchCriteria.getCreatedOnOrBefore());
addDateBounds(c, "dateChanged", searchCriteria.getChangedOnOrAfter(), searchCriteria.getChangedOnOrBefore());
addDateBounds(c, "dateVoided", searchCriteria.getVoidedOnOrAfter(), searchCriteria.getVoidedOnOrBefore());
addDateBounds(c, "encounterDatetime", searchCriteria.getEncounterDatetimeOnOrAfter(),
searchCriteria.getEncounterDatetimeOnOrBefore());
if (searchCriteria.getProvider() != null) {
// A subquery rather than a join, so that an encounter naming the provider more than
// once is still returned once — a join would need a distinct, and an in-memory distinct
// would be applied after paging had already counted the duplicate rows.
DetachedCriteria encountersNamingProvider = DetachedCriteria.forClass(EncounterProvider.class, "ep")
.createAlias("ep.encounter", "providerEncounter")
.setProjection(Projections.property("providerEncounter.encounterId"))
.add(eq("ep.provider", searchCriteria.getProvider()))
.add(eq("ep.voided", false));
c.add(Subqueries.propertyIn("encounterId", encountersNamingProvider));
}
if (applySortCriteria && searchCriteria.getSortCriteria() != null) {
for (SortCriteria sortCriteria : searchCriteria.getSortCriteria()) {
if (sortCriteria.getDirection() == SortCriteria.Direction.DESC) {
c.addOrder(desc(sortCriteria.getField()));
} else {
c.addOrder(asc(sortCriteria.getField()));
}
}
}
return c;
}

}
35 changes: 35 additions & 0 deletions api/src/main/java/org/openmrs/module/pihapps/PihAppsUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.stereotype.Component;

import java.util.ArrayDeque;
import java.util.Calendar;
import java.util.Date;
import java.util.Deque;
import java.util.HashSet;
import java.util.Locale;
Expand Down Expand Up @@ -101,6 +103,39 @@ else if (isShort && isEnglish) {
* @param root
* @return a set of Concepts that are recursive set members of root
*/
/**
* The last moment of a date's day if that date carries no time of day, and the date itself
* otherwise. Modelled on the reporting module's {@code DateUtil.getEndOfDayIfTimeExcluded}.
*
* <p>This is for the upper end of an inclusive range. Someone who names a day means the whole
* of it, so a bound of `2026-09-30` has to reach 23:59:59.999 or everything recorded after
* midnight on the 30th falls outside a range that plainly includes the 30th. A lower bound
* needs no such adjustment: midnight is already the first moment of its day.
*
* <p>A time of exactly midnight is read as no time of day, since a Date cannot say whether the
* caller wrote `2026-09-30` or `2026-09-30T00:00:00`. A caller that means that first instant
* and nothing more should bound the range a moment earlier.
*
* @param date the upper bound as given, or null for no bound
* @return the bound to search on, or null if none was given
*/
public static Date getEndOfDayIfTimeExcluded(Date date) {
if (date == null) {
return null;
}
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
if (calendar.get(Calendar.HOUR_OF_DAY) != 0 || calendar.get(Calendar.MINUTE) != 0
|| calendar.get(Calendar.SECOND) != 0 || calendar.get(Calendar.MILLISECOND) != 0) {
return date;
}
calendar.set(Calendar.HOUR_OF_DAY, 23);
calendar.set(Calendar.MINUTE, 59);
calendar.set(Calendar.SECOND, 59);
calendar.set(Calendar.MILLISECOND, 999);
return calendar.getTime();
}

public static Set<Concept> getConceptHierarchy(Concept root) {
Set<Concept> result = new HashSet<>();
Set<Integer> visited = new HashSet<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.openmrs.module.pihapps.encounter;

import lombok.Data;
import org.openmrs.EncounterType;
import org.openmrs.Provider;
import org.openmrs.User;
import org.openmrs.module.pihapps.SortCriteria;

import java.util.Date;
import java.util.List;

/**
* Describes an encounter search. Core's own {@link org.openmrs.parameter.EncounterSearchCriteria}
* carries a providers field that no search handler exposes, and has no creator, changedBy or
* voidedBy field at all, which is why this exists rather than the module reusing it.
*
* <p>Every filter narrows, so naming several asks for the encounters satisfying all of them, and
* naming none matches every encounter.
*/
@Data
public class EncounterSearchCriteria {

/**
* Whether voided encounters are returned alongside the surviving ones. Off by default, since a
* search is normally asking what a record says now, and callers can tell the two apart by each
* encounter's voided flag.
*
* <p>An audit search turns this on: voided encounters are the whole point of a
* {@link #voidedBy} search, and what an auditor most wants to see in the others.
*/
private boolean includeVoided = false;

/** Restrict to encounters this user created. */
private User createdBy;

/** Restrict to encounters this user changed. */
private User changedBy;

/** Restrict to encounters this user voided. */
private User voidedBy;

/** Restrict to encounters this provider is recorded on. */
private Provider provider;

/** Restrict to encounters of this type. */
private EncounterType encounterType;

/**
* Bound when the encounter was created. Each of the four ranges below names the column it
* applies to, and each is independent of the filters: `createdOnOrAfter` narrows by creation
* date whether or not `createdBy` is also given, and naming several asks for all of them.
*
* <p>Which range a search wants is the caller's to decide. An audit of what a user entered
* wants the range against that user's action — an encounter backdated to last year but entered
* this morning was entered this morning — while a provider's caseload is asked about by
* {@link #encounterDatetimeOnOrAfter}, when the encounters actually happened.
*
* <p>Both ends of every range run inclusively and are applied as given, so a caller that means
* a whole day passes that day's last moment.
*/
private Date createdOnOrAfter;

/** @see #createdOnOrAfter */
private Date createdOnOrBefore;

/** Bound when the encounter was last changed. @see #createdOnOrAfter */
private Date changedOnOrAfter;

/** @see #createdOnOrAfter */
private Date changedOnOrBefore;

/** Bound when the encounter was voided. @see #createdOnOrAfter */
private Date voidedOnOrAfter;

/** @see #createdOnOrAfter */
private Date voidedOnOrBefore;

/** Bound the encounter's own datetime — when it happened. @see #createdOnOrAfter */
private Date encounterDatetimeOnOrAfter;

/** @see #createdOnOrAfter */
private Date encounterDatetimeOnOrBefore;

private List<SortCriteria> sortCriteria;
private Integer startIndex;
private Integer limit;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.openmrs.module.pihapps.encounter;

import lombok.Data;
import org.openmrs.Encounter;

import java.util.List;

@Data
public class EncounterSearchResult {
Long totalCount;
List<Encounter> encounters;
}
Loading