Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public void handle(Patient patient, User originalVoidingUser, Date originalVoide

/**
* @return true if the entry was voided together with the patient and can be restored with it. An
* entry on a voided visit was taken down by VisitWithQueueEntriesSaveHandler, not by this
* entry on a voided visit was taken down by VisitWithQueueEntriesVoidHandler, not by this
* cascade; restoring it would put an active entry back on a visit that is still voided.
*/
private static boolean shouldRestore(QueueEntry qe, User originalVoidingUser, Date originalVoidedDate) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
* the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/
package org.openmrs.module.queue.api;

import java.util.List;

import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.openmrs.Visit;
import org.openmrs.api.context.Context;
import org.openmrs.module.queue.api.search.QueueEntrySearchCriteria;
import org.openmrs.module.queue.model.QueueEntry;
import org.openmrs.module.queue.utils.PrivilegeConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;

/**
* Purges the queue entries of a visit before {@link org.openmrs.api.VisitService#purgeVisit}
* deletes it. Core's purge cascade knows nothing about queue entries, so without this the delete
* fails on the queue_entry foreign key to visit.
* <p>
* Purging cannot be done from a handler. RequiredDataAdvice dispatches handlers only for method
* names beginning save, create, void, unvoid, retire or unretire, so a purge reaches none of them,
* and the module hooks the service instead. Voiding is owned by
* {@link VisitWithQueueEntriesVoidHandler}, which core does reach on the {@code voidVisit} path.
* <p>
* The cascade and core's own delete run in one transaction, taken out here, because the entries
* must not go without the visit. {@code purgeVisit} refuses a visit that still has encounters, and
* it does so after this advice has run, so the two have to stand or fall together.
* <p>
* The transaction is taken out here rather than relied upon, because {@code Context.addAdvice}
* gives no guarantee that one is open by the time the advice runs. Whether one is depends on how
* the platform wraps {@code visitService}, which is not this module's to depend on and has already
* changed between supported versions. Propagation is the default, so this joins a transaction where
* the platform provides one and opens its own where it does not. Do not remove it on the strength
* of a platform that provides one: {@code VisitWithQueueEntriesDeleteAdviceTransactionTest} is what
* says whether the entries survive a refused delete, and it is green either way on such a platform.
*/
public class VisitWithQueueEntriesDeleteAdvice implements MethodInterceptor {

private static final Logger log = LoggerFactory.getLogger(VisitWithQueueEntriesDeleteAdvice.class);

@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Object[] args = invocation.getArguments();
if (!"purgeVisit".equals(invocation.getMethod().getName()) || args.length == 0 || !(args[0] instanceof Visit)) {
return invocation.proceed();
}
Visit visit = (Visit) args[0];
if (visit.getVisitId() == null) {
return invocation.proceed();
}

// Looked up per call, not held in a field: ModuleUtil and DispatcherServlet re-run
// ModuleFactory.loadAdvice after a Spring refresh, and AdvicePoint hands them this same cached
// instance, so a field would outlive the context the bean came from
PlatformTransactionManager transactionManager = Context.getRegisteredComponent("transactionManager",
PlatformTransactionManager.class);
TransactionStatus transaction = transactionManager.getTransaction(TransactionDefinition.withDefaults());
Object result;
try {
purgeQueueEntries(visit);
result = invocation.proceed();
}
catch (Throwable t) {
try {
transactionManager.rollback(transaction);
}
catch (RuntimeException | Error rollbackFailure) {
// t is the only thing that says why the purge was refused, and the rollback failure is
// what propagates in its place. Suppressing it keeps it in the printed stack trace, so
// it reaches the server log; error responses built from getCause() will not show it.
rollbackFailure.addSuppressed(t);
throw rollbackFailure;
}
throw t;
}
transactionManager.commit(transaction);
return result;
}

private void purgeQueueEntries(Visit visit) {
// Purging a visit is driven by a core service whose callers need not hold queue privileges,
// so grant them for the duration of this cascade, as the queue handlers do
Context.addProxyPrivilege(PrivilegeConstants.GET_QUEUE_ENTRIES);
Context.addProxyPrivilege(PrivilegeConstants.PURGE_QUEUE_ENTRIES);
try {
QueueEntryService queueEntryService = Context.getService(QueueEntryService.class);
QueueEntrySearchCriteria criteria = new QueueEntrySearchCriteria();
criteria.setVisit(visit);
// voided entries hold the same foreign key, and a voided patient hides them from the default search
criteria.setIncludedVoided(true);
List<QueueEntry> queueEntries = queueEntryService.getQueueEntries(criteria);
if (!queueEntries.isEmpty()) {
log.debug("Purging {} queue entries of visit {} being purged", queueEntries.size(), visit.getVisitId());
}
for (QueueEntry qe : queueEntries) {
queueEntryService.purgeQueueEntry(qe);
log.trace("Purged queue entry {}", qe);
}
}
finally {
Context.removeProxyPrivilege(PrivilegeConstants.GET_QUEUE_ENTRIES);
Context.removeProxyPrivilege(PrivilegeConstants.PURGE_QUEUE_ENTRIES);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@
import org.openmrs.annotation.Handler;
import org.openmrs.api.context.Context;
import org.openmrs.api.handler.SaveHandler;
import org.openmrs.api.handler.VoidHandler;
import org.openmrs.module.queue.api.search.QueueEntrySearchCriteria;
import org.openmrs.module.queue.model.QueueEntry;
import org.openmrs.module.queue.utils.PrivilegeConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;

/**
* Ends a visit's open queue entries when the visit is stopped. Voiding is owned by
* {@link VisitWithQueueEntriesVoidHandler}, which core reaches on the {@code voidVisit} path, and
* purging by {@link VisitWithQueueEntriesDeleteAdvice}.
*/
@Handler(supports = Visit.class)
public class VisitWithQueueEntriesSaveHandler implements SaveHandler<Visit>, VoidHandler<Visit> {
public class VisitWithQueueEntriesSaveHandler implements SaveHandler<Visit> {

private final Log log = LogFactory.getLog(getClass());

Expand All @@ -40,7 +44,7 @@ public VisitWithQueueEntriesSaveHandler(@Qualifier("queue.QueueEntryService") Qu

@Override
public void handle(Visit visit, User user, Date date, String s) {
// Voiding or unvoiding is driven by core services whose callers need not hold queue privileges,
// Stopping a visit is driven by core services whose callers need not hold queue privileges,
// so grant them for the duration of this cascade, as core's PatientDataVoidHandler does
Context.addProxyPrivilege(PrivilegeConstants.GET_QUEUE_ENTRIES);
Context.addProxyPrivilege(PrivilegeConstants.MANAGE_QUEUE_ENTRIES);
Expand All @@ -59,23 +63,6 @@ public void handle(Visit visit, User user, Date date, String s) {
log.trace("Closed queue entry " + qe + " on " + visit.getStopDatetime());
}
}
if (visit.getVisitId() != null && visit.getVoided()) {
QueueEntrySearchCriteria criteria = new QueueEntrySearchCriteria();
criteria.setVisit(visit);
// The visit's patient may itself be voided, which would hide its entries from the default search
criteria.setIncludedVoided(true);
List<QueueEntry> queueEntries = queueEntryService.getQueueEntries(criteria);
for (QueueEntry qe : queueEntries) {
if (!qe.getVoided()) {
qe.setVoided(true);
qe.setVoidReason(visit.getVoidReason());
qe.setVoidedBy(visit.getVoidedBy());
qe.setDateVoided(visit.getDateVoided());
queueEntryService.saveQueueEntry(qe);
}
log.trace("Voided queue entry " + qe + " on " + date);
}
}
}
finally {
Context.removeProxyPrivilege(PrivilegeConstants.GET_QUEUE_ENTRIES);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
* the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/
package org.openmrs.module.queue.api;

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

import org.openmrs.User;
import org.openmrs.Visit;
import org.openmrs.annotation.Handler;
import org.openmrs.api.context.Context;
import org.openmrs.api.handler.VoidHandler;
import org.openmrs.module.queue.api.search.QueueEntrySearchCriteria;
import org.openmrs.module.queue.model.QueueEntry;
import org.openmrs.module.queue.utils.PrivilegeConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;

/**
* Voids all queue entries of a visit when that visit is voided. Core knows nothing about queue
* entries, so nothing in its void cascade touches them; without this handler a deleted visit would
* leave its entries active and the patient would stay in the service queue.
* <p>
* The void state is read from the handler arguments rather than from {@code visit.getVoided()}, so
* this does not depend on running after core's {@code BaseVoidHandler}. That matters: both handlers
* carry the default {@code @Handler} order, {@code HandlerUtil} sorts stably, and the tie is broken
* by the iteration order of the map {@code ServiceContext.getRegisteredComponents} builds, which
* moves with the number of {@code VoidHandler} beans a deployment happens to register. Core's own
* {@code VisitVoidHandler} takes the same approach for a visit's encounters.
* <p>
* {@code RequiredDataAdvice} passes every handler the same void date and reason it hands to
* {@code BaseVoidHandler}, so the entries carry the visit's own void stamp whichever runs first.
* Entries that were already voided keep the stamp they had, so an unvoid can tell them apart from
* entries taken down with the visit.
Comment on lines +42 to +43

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing performs that discrimination today, so this claims more than the module does. PatientWithQueueEntriesUnvoidHandler.shouldRestore is the only unvoid that looks at queue entries, and it rejects any entry whose visit is voided (PatientWithQueueEntriesUnvoidHandler.java:95) before it gets as far as comparing stamps, so for entries taken down with the visit the stamp plays no part. The stamp comparison below that check is answering a different question, telling the patient cascade's own entries apart from independently voided ones.

The reason is that there is no UnvoidHandler<Visit> anywhere in the module, which makes this cascade the odd one out among its siblings: core pairs VisitVoidHandler with VisitUnvoidHandler, and the module pairs PatientWithQueueEntriesVoidHandler with an unvoid handler of its own.

I'm not asking for that handler here, since O3-5459 already scopes it as a follow-up to be filed separately ("handle unvoidVisit so restoring a deleted visit also restores its queue entries"). It is reachable, for what it's worth: VisitResource1_9.undelete calls unvoidVisit, and POST /visit/{uuid} with {"voided": false} gets there. I searched O3 and couldn't find a ticket for it yet. What I would change is just the sentence, so that the next person reading shouldRestore's own comment (which now points at this class) isn't left to work the gap out for themselves.

Suggested change
* Entries that were already voided keep the stamp they had, so an unvoid can tell them apart from
* entries taken down with the visit.
* Entries that were already voided keep the stamp they had, so their own void is not overwritten.
* Nothing restores any of them: the module has no {@code UnvoidHandler<Visit>}, so unvoiding a
* visit leaves its queue entries voided.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{"voided": false} doesn't reach unvoidVisit, so the aside above names the wrong request. The Javadoc suggestion doesn't depend on it, so nothing changes in the PR.

MainResourceController.update only routes a POST to undelete when the body is exactly {"deleted": "false"}, and every other body goes through DelegatingCrudResource.update to saveVisit. {"voided": false} is the body the chart's "Restore visit" sends (restoreVisit in visit.resource.tsx). I ran both through webservices.rest 3.0.0's own controller test harness with an advice on VisitService recording each call: {"voided": false} reached saveVisit and never unvoidVisit, and {"deleted": "false"} reached unvoidVisit.

Where it does matter is the unvoid follow-up. When that gets filed, could it note that an UnvoidHandler<Visit> on its own won't fire when the chart restores a visit?

*/
@Handler(supports = Visit.class)
public class VisitWithQueueEntriesVoidHandler implements VoidHandler<Visit> {

private static final Logger log = LoggerFactory.getLogger(VisitWithQueueEntriesVoidHandler.class);

private final QueueEntryService queueEntryService;

@Autowired
public VisitWithQueueEntriesVoidHandler(@Qualifier("queue.QueueEntryService") QueueEntryService queueEntryService) {
this.queueEntryService = queueEntryService;
}

@Override
public void handle(Visit visit, User voidingUser, Date voidedDate, String voidReason) {
if (visit.getVisitId() == null) {
return;
}
// Voiding is driven by core services whose callers need not hold queue privileges, so grant
// them for the duration of this cascade, as core's PatientDataVoidHandler does
Context.addProxyPrivilege(PrivilegeConstants.GET_QUEUE_ENTRIES);
Context.addProxyPrivilege(PrivilegeConstants.MANAGE_QUEUE_ENTRIES);
try {
QueueEntrySearchCriteria criteria = new QueueEntrySearchCriteria();
criteria.setVisit(visit);
// The visit's patient may itself be voided, which would hide its entries from the default search
criteria.setIncludedVoided(true);
List<QueueEntry> queueEntries = queueEntryService.getQueueEntries(criteria);
int voidedCount = 0;
for (QueueEntry qe : queueEntries) {
if (qe.getVoided()) {
continue;
}
qe.setVoided(true);
qe.setVoidReason(voidReason);
qe.setVoidedBy(voidingUser);
qe.setDateVoided(voidedDate);
queueEntryService.saveQueueEntry(qe);
voidedCount++;
log.trace("Voided queue entry {} on {}", qe, voidedDate);
}
if (voidedCount > 0) {
log.info("Voided {} queue entries of visit {} with reason: {}", voidedCount, visit.getVisitId(), voidReason);
}
}
finally {
Context.removeProxyPrivilege(PrivilegeConstants.GET_QUEUE_ENTRIES);
Context.removeProxyPrivilege(PrivilegeConstants.MANAGE_QUEUE_ENTRIES);
}
}
}
Loading