Ongoing work on subscriptions
This commit is contained in:
parent
836d4d051b
commit
736e037b1a
|
@ -214,6 +214,12 @@ public class ValidatorExamples {
|
|||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IBaseResource> fetchAllConformanceResources(FhirContext theContext) {
|
||||
// TODO: implement
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StructureDefinition> fetchAllStructureDefinitions(FhirContext theContext) {
|
||||
// TODO: implement
|
||||
|
|
|
@ -165,11 +165,6 @@ public abstract class BaseHapiFhirDao<T extends IBaseResource> implements IDao {
|
|||
@Autowired
|
||||
private IResourceIndexedCompositeStringUniqueDao myResourceIndexedCompositeStringUniqueDao;
|
||||
|
||||
private <T extends IBaseResource> void autoCreateResource(T theResource) {
|
||||
IFhirResourceDao<T> dao = (IFhirResourceDao<T>) getDao(theResource.getClass());
|
||||
dao.create(theResource);
|
||||
}
|
||||
|
||||
protected void clearRequestAsProcessingSubRequest(ServletRequestDetails theRequestDetails) {
|
||||
if (theRequestDetails != null) {
|
||||
theRequestDetails.getUserData().remove(PROCESSING_SUB_REQUEST);
|
||||
|
@ -423,11 +418,13 @@ public abstract class BaseHapiFhirDao<T extends IBaseResource> implements IDao {
|
|||
if (getConfig().isAutoCreatePlaceholderReferenceTargets()) {
|
||||
IBaseResource newResource = missingResourceDef.newInstance();
|
||||
newResource.setId(resName + "/" + id);
|
||||
autoCreateResource(newResource);
|
||||
}
|
||||
|
||||
IFhirResourceDao<IBaseResource> placeholderResourceDao = (IFhirResourceDao<IBaseResource>) getDao(newResource.getClass());
|
||||
ourLog.info("Automatically creating empty placeholder resource: {}", newResource.getIdElement().getValue());
|
||||
valueOf = placeholderResourceDao.update(newResource).getEntity().getId();
|
||||
} else {
|
||||
throw new InvalidRequestException("Resource " + resName + "/" + id + " not found, specified in path: " + nextPathsUnsplit);
|
||||
}
|
||||
}
|
||||
ResourceTable target = myEntityManager.find(ResourceTable.class, valueOf);
|
||||
RuntimeResourceDefinition targetResourceDef = getContext().getResourceDefinition(type);
|
||||
if (target == null) {
|
||||
|
|
|
@ -662,6 +662,7 @@ public class FhirSystemDaoDstu3 extends BaseHapiFhirSystemDao<Bundle, Meta> {
|
|||
String nextReplacementIdPart = nextReplacementId.getValueAsString();
|
||||
if (nextTemporaryId.isUrn() && nextTemporaryIdPart.length() > IdType.URN_PREFIX.length()) {
|
||||
matchUrl = matchUrl.replace(nextTemporaryIdPart, nextReplacementIdPart);
|
||||
matchUrl = matchUrl.replace(UrlUtil.escape(nextTemporaryIdPart), nextReplacementIdPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -662,6 +662,7 @@ public class FhirSystemDaoR4 extends BaseHapiFhirSystemDao<Bundle, Meta> {
|
|||
String nextReplacementIdPart = nextReplacementId.getValueAsString();
|
||||
if (nextTemporaryId.isUrn() && nextTemporaryIdPart.length() > IdType.URN_PREFIX.length()) {
|
||||
matchUrl = matchUrl.replace(nextTemporaryIdPart, nextReplacementIdPart);
|
||||
matchUrl = matchUrl.replace(UrlUtil.escape(nextTemporaryIdPart), nextReplacementIdPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,10 +46,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
|||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
public abstract class BaseSubscriptionInterceptor extends ServerOperationInterceptorAdapter {
|
||||
|
@ -63,13 +60,15 @@ public abstract class BaseSubscriptionInterceptor extends ServerOperationInterce
|
|||
private static final Integer MAX_SUBSCRIPTION_RESULTS = 1000;
|
||||
private SubscribableChannel myProcessingChannel;
|
||||
private SubscribableChannel myDeliveryChannel;
|
||||
private ExecutorService myExecutor;
|
||||
private ExecutorService myProcessingExecutor;
|
||||
private int myExecutorThreadCount;
|
||||
private SubscriptionActivatingSubscriber mySubscriptionActivatingSubscriber;
|
||||
private MessageHandler mySubscriptionCheckingSubscriber;
|
||||
private ConcurrentHashMap<String, IBaseResource> myIdToSubscription = new ConcurrentHashMap<>();
|
||||
private Logger ourLog = LoggerFactory.getLogger(BaseSubscriptionInterceptor.class);
|
||||
private BlockingQueue<Runnable> myExecutorQueue;
|
||||
private ThreadPoolExecutor myDeliveryExecutor;
|
||||
private LinkedBlockingQueue<Runnable> myProcessingExecutorQueue;
|
||||
private LinkedBlockingQueue<Runnable> myDeliveryExecutorQueue;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
|
@ -89,8 +88,8 @@ public abstract class BaseSubscriptionInterceptor extends ServerOperationInterce
|
|||
myDeliveryChannel = theDeliveryChannel;
|
||||
}
|
||||
|
||||
public BlockingQueue<Runnable> getExecutorQueueForUnitTests() {
|
||||
return myExecutorQueue;
|
||||
public int getExecutorQueueSizeForUnitTests() {
|
||||
return myProcessingExecutorQueue.size() + myDeliveryExecutorQueue.size();
|
||||
}
|
||||
|
||||
public int getExecutorThreadCount() {
|
||||
|
@ -157,15 +156,16 @@ public abstract class BaseSubscriptionInterceptor extends ServerOperationInterce
|
|||
|
||||
@PostConstruct
|
||||
public void postConstruct() {
|
||||
myExecutorQueue = new LinkedBlockingQueue<>(1000);
|
||||
{
|
||||
myProcessingExecutorQueue = new LinkedBlockingQueue<>(1000);
|
||||
|
||||
RejectedExecutionHandler rejectedExecutionHandler = new RejectedExecutionHandler() {
|
||||
@Override
|
||||
public void rejectedExecution(Runnable theRunnable, ThreadPoolExecutor theExecutor) {
|
||||
ourLog.info("Note: Executor queue is full ({} elements), waiting for a slot to become available!", myExecutorQueue.size());
|
||||
ourLog.info("Note: Executor queue is full ({} elements), waiting for a slot to become available!", myProcessingExecutorQueue.size());
|
||||
StopWatch sw = new StopWatch();
|
||||
try {
|
||||
myExecutorQueue.put(theRunnable);
|
||||
myProcessingExecutorQueue.put(theRunnable);
|
||||
} catch (InterruptedException theE) {
|
||||
throw new RejectedExecutionException("Task " + theRunnable.toString() +
|
||||
" rejected from " + theE.toString());
|
||||
|
@ -174,25 +174,55 @@ public abstract class BaseSubscriptionInterceptor extends ServerOperationInterce
|
|||
}
|
||||
};
|
||||
ThreadFactory threadFactory = new BasicThreadFactory.Builder()
|
||||
.namingPattern("subscription-%d")
|
||||
.namingPattern("subscription-proc-%d")
|
||||
.daemon(false)
|
||||
.priority(Thread.NORM_PRIORITY)
|
||||
.build();
|
||||
myExecutor = new ThreadPoolExecutor(
|
||||
myProcessingExecutor = new ThreadPoolExecutor(
|
||||
1,
|
||||
getExecutorThreadCount(),
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS,
|
||||
myExecutorQueue,
|
||||
myProcessingExecutorQueue,
|
||||
threadFactory,
|
||||
rejectedExecutionHandler);
|
||||
|
||||
}
|
||||
{
|
||||
myDeliveryExecutorQueue = new LinkedBlockingQueue<>(1000);
|
||||
BasicThreadFactory threadFactory = new BasicThreadFactory.Builder()
|
||||
.namingPattern("subscription-delivery-%d")
|
||||
.daemon(false)
|
||||
.priority(Thread.NORM_PRIORITY)
|
||||
.build();
|
||||
RejectedExecutionHandler rejectedExecutionHandler2 = new RejectedExecutionHandler() {
|
||||
@Override
|
||||
public void rejectedExecution(Runnable theRunnable, ThreadPoolExecutor theExecutor) {
|
||||
ourLog.info("Note: Executor queue is full ({} elements), waiting for a slot to become available!", myDeliveryExecutorQueue.size());
|
||||
StopWatch sw = new StopWatch();
|
||||
try {
|
||||
myDeliveryExecutorQueue.put(theRunnable);
|
||||
} catch (InterruptedException theE) {
|
||||
throw new RejectedExecutionException("Task " + theRunnable.toString() +
|
||||
" rejected from " + theE.toString());
|
||||
}
|
||||
ourLog.info("Slot become available after {}ms", sw.getMillis());
|
||||
}
|
||||
};
|
||||
myDeliveryExecutor = new ThreadPoolExecutor(
|
||||
1,
|
||||
getExecutorThreadCount(),
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS,
|
||||
myDeliveryExecutorQueue,
|
||||
threadFactory,
|
||||
rejectedExecutionHandler2);
|
||||
}
|
||||
|
||||
if (getProcessingChannel() == null) {
|
||||
setProcessingChannel(new ExecutorSubscribableChannel(myExecutor));
|
||||
setProcessingChannel(new ExecutorSubscribableChannel(myProcessingExecutor));
|
||||
}
|
||||
if (getDeliveryChannel() == null) {
|
||||
setDeliveryChannel(new ExecutorSubscribableChannel(myExecutor));
|
||||
setDeliveryChannel(new ExecutorSubscribableChannel(myDeliveryExecutor));
|
||||
}
|
||||
|
||||
if (mySubscriptionActivatingSubscriber == null) {
|
||||
|
|
|
@ -21,12 +21,14 @@ package ca.uhn.fhir.jpa.subscription;
|
|||
*/
|
||||
|
||||
import ca.uhn.fhir.jpa.dao.IFhirResourceDao;
|
||||
import ca.uhn.fhir.rest.api.Constants;
|
||||
import ca.uhn.fhir.rest.api.EncodingEnum;
|
||||
import ca.uhn.fhir.rest.client.api.IGenericClient;
|
||||
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
|
||||
import ca.uhn.fhir.rest.client.interceptor.SimpleRequestHeaderInterceptor;
|
||||
import ca.uhn.fhir.rest.gclient.IClientExecutable;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.hl7.fhir.instance.model.api.IBaseResource;
|
||||
import org.hl7.fhir.instance.model.api.IPrimitiveType;
|
||||
import org.hl7.fhir.r4.model.Subscription;
|
||||
|
@ -78,7 +80,7 @@ public class SubscriptionDeliveringRestHookSubscriber extends BaseSubscriptionSu
|
|||
if (!(theMessage.getPayload() instanceof ResourceDeliveryMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ResourceDeliveryMessage msg = (ResourceDeliveryMessage) theMessage.getPayload();
|
||||
|
||||
if (!subscriptionTypeApplies(getContext(), msg.getSubscription())) {
|
||||
|
@ -90,11 +92,12 @@ public class SubscriptionDeliveringRestHookSubscriber extends BaseSubscriptionSu
|
|||
|
||||
// Grab the endpoint from the subscription
|
||||
IPrimitiveType<?> endpoint = getContext().newTerser().getSingleValueOrNull(subscription, BaseSubscriptionInterceptor.SUBSCRIPTION_ENDPOINT, IPrimitiveType.class);
|
||||
String endpointUrl = endpoint.getValueAsString();
|
||||
String endpointUrl = endpoint != null ? endpoint.getValueAsString() : null;
|
||||
|
||||
// Grab the payload type (encoding mimetype) from the subscription
|
||||
IPrimitiveType<?> payload = getContext().newTerser().getSingleValueOrNull(subscription, BaseSubscriptionInterceptor.SUBSCRIPTION_PAYLOAD, IPrimitiveType.class);
|
||||
String payloadString = payload.getValueAsString();
|
||||
String payloadString = payload != null ? payload.getValueAsString() : null;
|
||||
payloadString = StringUtils.defaultString(payloadString, Constants.CT_FHIR_XML_NEW);
|
||||
if (payloadString.contains(";")) {
|
||||
payloadString = payloadString.substring(0, payloadString.indexOf(';'));
|
||||
}
|
||||
|
@ -104,7 +107,9 @@ public class SubscriptionDeliveringRestHookSubscriber extends BaseSubscriptionSu
|
|||
|
||||
// Create the client request
|
||||
getContext().getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
|
||||
IGenericClient client = getContext().newRestfulGenericClient(endpointUrl);
|
||||
IGenericClient client = null;
|
||||
if (isNotBlank(endpointUrl)) {
|
||||
client = getContext().newRestfulGenericClient(endpointUrl);
|
||||
|
||||
// Additional headers specified in the subscription
|
||||
List<IPrimitiveType> headers = getContext().newTerser().getValues(subscription, BaseSubscriptionInterceptor.SUBSCRIPTION_HEADER, IPrimitiveType.class);
|
||||
|
@ -113,9 +118,13 @@ public class SubscriptionDeliveringRestHookSubscriber extends BaseSubscriptionSu
|
|||
client.registerInterceptor(new SimpleRequestHeaderInterceptor(next.getValueAsString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deliverPayload(msg, subscription, payloadType, client);
|
||||
|
||||
} catch (Exception e) {
|
||||
ourLog.error("Failure handling subscription payload", e);
|
||||
throw new MessagingException(theMessage, "Failure handling subscription payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -57,6 +57,9 @@ public abstract class BaseJpaDstu3Test extends BaseJpaTest {
|
|||
private static JpaValidationSupportChainDstu3 ourJpaValidationSupportChainDstu3;
|
||||
private static IFhirResourceDaoValueSet<ValueSet, Coding, CodeableConcept> ourValueSetDao;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("myCoverageDaoDstu3")
|
||||
protected IFhirResourceDao<Coverage> myCoverageDao;
|
||||
@Autowired
|
||||
protected IResourceIndexedCompositeStringUniqueDao myResourceIndexedCompositeStringUniqueDao;
|
||||
@Autowired
|
||||
|
|
|
@ -9,6 +9,7 @@ import ca.uhn.fhir.jpa.util.JpaConstants;
|
|||
import ca.uhn.fhir.rest.api.server.IBundleProvider;
|
||||
import ca.uhn.fhir.rest.param.DateParam;
|
||||
import ca.uhn.fhir.rest.param.TokenParam;
|
||||
import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
|
||||
import ca.uhn.fhir.util.TestUtil;
|
||||
import org.hl7.fhir.dstu3.model.*;
|
||||
import org.hl7.fhir.dstu3.model.Enumerations.PublicationStatus;
|
||||
|
@ -36,6 +37,185 @@ public class FhirResourceDaoDstu3UniqueSearchParamTest extends BaseJpaDstu3Test
|
|||
myDaoConfig.setDefaultSearchParamsCanBeOverridden(new DaoConfig().isDefaultSearchParamsCanBeOverridden());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
public void testIndexTransactionWithMatchUrl() {
|
||||
Patient pt2 = new Patient();
|
||||
pt2.setGender(Enumerations.AdministrativeGender.MALE);
|
||||
pt2.setBirthDateElement(new DateType("2011-01-02"));
|
||||
IIdType id2 = myPatientDao.create(pt2).getId().toUnqualifiedVersionless();
|
||||
|
||||
Coverage cov = new Coverage();
|
||||
cov.getBeneficiary().setReference(id2.getValue());
|
||||
cov.addIdentifier().setSystem("urn:foo:bar").setValue("123");
|
||||
IIdType id3 = myCoverageDao.create(cov).getId().toUnqualifiedVersionless();
|
||||
|
||||
createUniqueIndexCoverageBeneficiary();
|
||||
|
||||
mySystemDao.markAllResourcesForReindexing();
|
||||
mySystemDao.performReindexingPass(1000);
|
||||
|
||||
List<ResourceIndexedCompositeStringUnique> uniques = myResourceIndexedCompositeStringUniqueDao.findAll();
|
||||
assertEquals(uniques.toString(), 1, uniques.size());
|
||||
assertEquals("Coverage/" + id3.getIdPart(), uniques.get(0).getResource().getIdDt().toUnqualifiedVersionless().getValue());
|
||||
assertEquals("Coverage?beneficiary=Patient%2F" + id2.getIdPart() + "&identifier=urn%3Afoo%3Abar%7C123", uniques.get(0).getIndexString());
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testIndexTransactionWithMatchUrl2() {
|
||||
createUniqueIndexCoverageBeneficiary();
|
||||
|
||||
String input = "{\n" +
|
||||
" \"resourceType\": \"Bundle\",\n" +
|
||||
" \"type\": \"transaction\",\n" +
|
||||
" \"entry\": [\n" +
|
||||
" {\n" +
|
||||
" \"fullUrl\": \"urn:uuid:d2a46176-8e15-405d-bbda-baea1a9dc7f3\",\n" +
|
||||
" \"resource\": {\n" +
|
||||
" \"resourceType\": \"Patient\",\n" +
|
||||
" \"identifier\": [\n" +
|
||||
" {\n" +
|
||||
" \"use\": \"official\",\n" +
|
||||
" \"type\": {\n" +
|
||||
" \"coding\": [\n" +
|
||||
" {\n" +
|
||||
" \"system\": \"http://hl7.org/fhir/v2/0203\",\n" +
|
||||
" \"code\": \"MR\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" \"system\": \"FOOORG:FOOSITE:patientid:MR:R\",\n" +
|
||||
" \"value\": \"007811959\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" \"request\": {\n" +
|
||||
" \"method\": \"PUT\",\n" +
|
||||
" \"url\": \"/Patient?identifier=FOOORG%3AFOOSITE%3Apatientid%3AMR%3AR%7C007811959%2CFOOORG%3AFOOSITE%3Apatientid%3AMR%3AB%7C000929990%2CFOOORG%3AFOOSITE%3Apatientid%3API%3APH%7C00589363%2Chttp%3A%2F%2Fhl7.org%2Ffhir%2Fsid%2Fus-ssn%7C657-01-8133\"\n" +
|
||||
" }\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"fullUrl\": \"urn:uuid:b58ff639-11d1-4dac-942f-abf4f9a625d7\",\n" +
|
||||
" \"resource\": {\n" +
|
||||
" \"resourceType\": \"Coverage\",\n" +
|
||||
" \"identifier\": [\n" +
|
||||
" {\n" +
|
||||
" \"system\": \"FOOORG:FOOSITE:coverage:planId\",\n" +
|
||||
" \"value\": \"0403-010101\"\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"beneficiary\": {\n" +
|
||||
" \"reference\": \"urn:uuid:d2a46176-8e15-405d-bbda-baea1a9dc7f3\"\n" +
|
||||
" }\n" +
|
||||
" },\n" +
|
||||
" \"request\": {\n" +
|
||||
" \"method\": \"PUT\",\n" +
|
||||
" \"url\": \"/Coverage?beneficiary=urn%3Auuid%3Ad2a46176-8e15-405d-bbda-baea1a9dc7f3&identifier=FOOORG%3AFOOSITE%3Acoverage%3AplanId%7C0403-010101\"\n" +
|
||||
" }\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"fullUrl\": \"urn:uuid:13f5da1a-6601-4c1a-82c9-41527be23fa0\",\n" +
|
||||
" \"resource\": {\n" +
|
||||
" \"resourceType\": \"Coverage\",\n" +
|
||||
" \"contained\": [\n" +
|
||||
" {\n" +
|
||||
" \"resourceType\": \"RelatedPerson\",\n" +
|
||||
" \"id\": \"1\",\n" +
|
||||
" \"name\": [\n" +
|
||||
" {\n" +
|
||||
" \"family\": \"SMITH\",\n" +
|
||||
" \"given\": [\n" +
|
||||
" \"FAKER\"\n" +
|
||||
" ]\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"resourceType\": \"Organization\",\n" +
|
||||
" \"id\": \"2\",\n" +
|
||||
" \"name\": \"MEDICAID\"\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"identifier\": [\n" +
|
||||
" {\n" +
|
||||
" \"system\": \"FOOORG:FOOSITE:coverage:planId\",\n" +
|
||||
" \"value\": \"0404-010101\"\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"policyHolder\": {\n" +
|
||||
" \"reference\": \"#1\"\n" +
|
||||
" },\n" +
|
||||
" \"beneficiary\": {\n" +
|
||||
" \"reference\": \"urn:uuid:d2a46176-8e15-405d-bbda-baea1a9dc7f3\"\n" +
|
||||
" },\n" +
|
||||
" \"payor\": [\n" +
|
||||
" {\n" +
|
||||
" \"reference\": \"#2\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" \"request\": {\n" +
|
||||
" \"method\": \"PUT\",\n" +
|
||||
" \"url\": \"/Coverage?beneficiary=urn%3Auuid%3Ad2a46176-8e15-405d-bbda-baea1a9dc7f3&identifier=FOOORG%3AFOOSITE%3Acoverage%3AplanId%7C0404-010101\"\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
"}";
|
||||
|
||||
Bundle inputBundle = myFhirCtx.newJsonParser().parseResource(Bundle.class, input);
|
||||
ourLog.info(myFhirCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(inputBundle));
|
||||
mySystemDao.transaction(mySrd, inputBundle);
|
||||
|
||||
inputBundle = myFhirCtx.newJsonParser().parseResource(Bundle.class, input);
|
||||
mySystemDao.transaction(mySrd, inputBundle);
|
||||
|
||||
}
|
||||
|
||||
|
||||
private void createUniqueIndexCoverageBeneficiary() {
|
||||
SearchParameter sp = new SearchParameter();
|
||||
sp.setId("SearchParameter/coverage-beneficiary");
|
||||
sp.setCode("beneficiary");
|
||||
sp.setExpression("Coverage.beneficiary");
|
||||
sp.setType(Enumerations.SearchParamType.REFERENCE);
|
||||
sp.setStatus(PublicationStatus.ACTIVE);
|
||||
sp.addBase("Coverage");
|
||||
mySearchParameterDao.update(sp);
|
||||
|
||||
sp = new SearchParameter();
|
||||
sp.setId("SearchParameter/coverage-identifier");
|
||||
sp.setCode("identifier");
|
||||
sp.setExpression("Coverage.identifier");
|
||||
sp.setType(Enumerations.SearchParamType.TOKEN);
|
||||
sp.setStatus(PublicationStatus.ACTIVE);
|
||||
sp.addBase("Coverage");
|
||||
mySearchParameterDao.update(sp);
|
||||
|
||||
sp = new SearchParameter();
|
||||
sp.setId("SearchParameter/coverage-beneficiary-identifier");
|
||||
sp.setCode("coverage-beneficiary-identifier");
|
||||
sp.setExpression("Coverage.beneficiary");
|
||||
sp.setType(Enumerations.SearchParamType.COMPOSITE);
|
||||
sp.setStatus(PublicationStatus.ACTIVE);
|
||||
sp.addBase("Coverage");
|
||||
sp.addComponent()
|
||||
.setExpression("Coverage")
|
||||
.setDefinition(new Reference("/SearchParameter/coverage-beneficiary"));
|
||||
sp.addComponent()
|
||||
.setExpression("Coverage")
|
||||
.setDefinition(new Reference("/SearchParameter/coverage-identifier"));
|
||||
sp.addExtension()
|
||||
.setUrl(JpaConstants.EXT_SP_UNIQUE)
|
||||
.setValue(new BooleanType(true));
|
||||
mySearchParameterDao.update(sp);
|
||||
mySearchParamRegsitry.forceRefresh();
|
||||
}
|
||||
|
||||
|
||||
@Before
|
||||
public void before() {
|
||||
myDaoConfig.setDefaultSearchParamsCanBeOverridden(true);
|
||||
|
@ -143,7 +323,7 @@ public class FhirResourceDaoDstu3UniqueSearchParamTest extends BaseJpaDstu3Test
|
|||
try {
|
||||
myPatientDao.create(pt1).getId().toUnqualifiedVersionless();
|
||||
fail();
|
||||
} catch (JpaSystemException e) {
|
||||
} catch (PreconditionFailedException e) {
|
||||
// good
|
||||
}
|
||||
|
||||
|
|
|
@ -146,6 +146,9 @@ public abstract class BaseJpaR4Test extends BaseJpaTest {
|
|||
@Qualifier("myPatientDaoR4")
|
||||
protected IFhirResourceDaoPatient<Patient> myPatientDao;
|
||||
@Autowired
|
||||
@Qualifier("myCoverageDaoR4")
|
||||
protected IFhirResourceDao<Coverage> myCoverageDao;
|
||||
@Autowired
|
||||
@Qualifier("myPractitionerDaoR4")
|
||||
protected IFhirResourceDao<Practitioner> myPractitionerDao;
|
||||
@Autowired
|
||||
|
|
|
@ -83,12 +83,32 @@ public class FhirResourceDaoCreatePlaceholdersR4Test extends BaseJpaR4Test {
|
|||
Observation o = new Observation();
|
||||
o.setStatus(ObservationStatus.FINAL);
|
||||
o.getSubject().setReference("Patient/FOO");
|
||||
try {
|
||||
myObservationDao.create(o, mySrd);
|
||||
fail();
|
||||
} catch (InvalidRequestException e) {
|
||||
assertEquals("Resource Patient/FOO not found, specified in path: Observation.subject", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateWithMultiplePlaceholders() {
|
||||
myDaoConfig.setAutoCreatePlaceholderReferenceTargets(true);
|
||||
|
||||
Task task = new Task();
|
||||
task.addNote().setText("A note");
|
||||
task.addPartOf().setReference("Task/AAA");
|
||||
task.addPartOf().setReference("Task/AAA");
|
||||
task.addPartOf().setReference("Task/AAA");
|
||||
IIdType id = myTaskDao.create(task).getId().toUnqualifiedVersionless();
|
||||
|
||||
task = myTaskDao.read(id);
|
||||
assertEquals(3, task.getPartOf().size());
|
||||
assertEquals("Task/AAA", task.getPartOf().get(0).getReference());
|
||||
assertEquals("Task/AAA", task.getPartOf().get(1).getReference());
|
||||
assertEquals("Task/AAA", task.getPartOf().get(2).getReference());
|
||||
|
||||
SearchParameterMap params = new SearchParameterMap();
|
||||
params.add(Task.SP_PART_OF, new ReferenceParam("Task/AAA"));
|
||||
List<String> found = toUnqualifiedVersionlessIdValues(myTaskDao.search(params));
|
||||
assertThat(found, contains(id.getValue()));
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -123,12 +143,7 @@ public class FhirResourceDaoCreatePlaceholdersR4Test extends BaseJpaR4Test {
|
|||
o.setId(id);
|
||||
o.setStatus(ObservationStatus.FINAL);
|
||||
o.getSubject().setReference("Patient/FOO");
|
||||
try {
|
||||
myObservationDao.update(o, mySrd);
|
||||
fail();
|
||||
} catch (InvalidRequestException e) {
|
||||
assertEquals("Resource Patient/FOO not found, specified in path: Observation.subject", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
|
|
|
@ -22,6 +22,7 @@ import org.springframework.orm.jpa.JpaSystemException;
|
|||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||
import static org.hamcrest.Matchers.empty;
|
||||
|
@ -346,6 +347,182 @@ public class FhirResourceDaoR4UniqueSearchParamTest extends BaseJpaR4Test {
|
|||
|
||||
}
|
||||
|
||||
|
||||
private void createUniqueIndexCoverageBeneficiary() {
|
||||
SearchParameter sp = new SearchParameter();
|
||||
sp.setId("SearchParameter/coverage-beneficiary");
|
||||
sp.setCode("beneficiary");
|
||||
sp.setExpression("Coverage.beneficiary");
|
||||
sp.setType(Enumerations.SearchParamType.REFERENCE);
|
||||
sp.setStatus(PublicationStatus.ACTIVE);
|
||||
sp.addBase("Coverage");
|
||||
mySearchParameterDao.update(sp);
|
||||
|
||||
sp = new SearchParameter();
|
||||
sp.setId("SearchParameter/coverage-identifier");
|
||||
sp.setCode("identifier");
|
||||
sp.setExpression("Coverage.identifier");
|
||||
sp.setType(Enumerations.SearchParamType.TOKEN);
|
||||
sp.setStatus(PublicationStatus.ACTIVE);
|
||||
sp.addBase("Coverage");
|
||||
mySearchParameterDao.update(sp);
|
||||
|
||||
sp = new SearchParameter();
|
||||
sp.setId("SearchParameter/coverage-beneficiary-identifier");
|
||||
sp.setCode("coverage-beneficiary-identifier");
|
||||
sp.setExpression("Coverage.beneficiary");
|
||||
sp.setType(Enumerations.SearchParamType.COMPOSITE);
|
||||
sp.setStatus(PublicationStatus.ACTIVE);
|
||||
sp.addBase("Coverage");
|
||||
sp.addComponent()
|
||||
.setExpression("Coverage")
|
||||
.setDefinition(new Reference("/SearchParameter/coverage-beneficiary"));
|
||||
sp.addComponent()
|
||||
.setExpression("Coverage")
|
||||
.setDefinition(new Reference("/SearchParameter/coverage-identifier"));
|
||||
sp.addExtension()
|
||||
.setUrl(JpaConstants.EXT_SP_UNIQUE)
|
||||
.setValue(new BooleanType(true));
|
||||
mySearchParameterDao.update(sp);
|
||||
mySearchParamRegsitry.forceRefresh();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIndexTransactionWithMatchUrl() {
|
||||
Patient pt2 = new Patient();
|
||||
pt2.setGender(Enumerations.AdministrativeGender.MALE);
|
||||
pt2.setBirthDateElement(new DateType("2011-01-02"));
|
||||
IIdType id2 = myPatientDao.create(pt2).getId().toUnqualifiedVersionless();
|
||||
|
||||
Coverage cov = new Coverage();
|
||||
cov.getBeneficiary().setReference(id2.getValue());
|
||||
cov.addIdentifier().setSystem("urn:foo:bar").setValue("123");
|
||||
IIdType id3 = myCoverageDao.create(cov).getId().toUnqualifiedVersionless();
|
||||
|
||||
createUniqueIndexCoverageBeneficiary();
|
||||
|
||||
mySystemDao.markAllResourcesForReindexing();
|
||||
mySystemDao.performReindexingPass(1000);
|
||||
|
||||
List<ResourceIndexedCompositeStringUnique> uniques = myResourceIndexedCompositeStringUniqueDao.findAll();
|
||||
assertEquals(uniques.toString(), 1, uniques.size());
|
||||
assertEquals("Coverage/" + id3.getIdPart(), uniques.get(0).getResource().getIdDt().toUnqualifiedVersionless().getValue());
|
||||
assertEquals("Coverage?beneficiary=Patient%2F" + id2.getIdPart() + "&identifier=urn%3Afoo%3Abar%7C123", uniques.get(0).getIndexString());
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIndexTransactionWithMatchUrl2() {
|
||||
createUniqueIndexCoverageBeneficiary();
|
||||
|
||||
String input = "{\n" +
|
||||
" \"resourceType\": \"Bundle\",\n" +
|
||||
" \"type\": \"transaction\",\n" +
|
||||
" \"entry\": [\n" +
|
||||
" {\n" +
|
||||
" \"fullUrl\": \"urn:uuid:d2a46176-8e15-405d-bbda-baea1a9dc7f3\",\n" +
|
||||
" \"resource\": {\n" +
|
||||
" \"resourceType\": \"Patient\",\n" +
|
||||
" \"identifier\": [\n" +
|
||||
" {\n" +
|
||||
" \"use\": \"official\",\n" +
|
||||
" \"type\": {\n" +
|
||||
" \"coding\": [\n" +
|
||||
" {\n" +
|
||||
" \"system\": \"http://hl7.org/fhir/v2/0203\",\n" +
|
||||
" \"code\": \"MR\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" \"system\": \"FOOORG:FOOSITE:patientid:MR:R\",\n" +
|
||||
" \"value\": \"007811959\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" \"request\": {\n" +
|
||||
" \"method\": \"PUT\",\n" +
|
||||
" \"url\": \"/Patient?identifier=FOOORG%3AFOOSITE%3Apatientid%3AMR%3AR%7C007811959%2CFOOORG%3AFOOSITE%3Apatientid%3AMR%3AB%7C000929990%2CFOOORG%3AFOOSITE%3Apatientid%3API%3APH%7C00589363%2Chttp%3A%2F%2Fhl7.org%2Ffhir%2Fsid%2Fus-ssn%7C657-01-8133\"\n" +
|
||||
" }\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"fullUrl\": \"urn:uuid:b58ff639-11d1-4dac-942f-abf4f9a625d7\",\n" +
|
||||
" \"resource\": {\n" +
|
||||
" \"resourceType\": \"Coverage\",\n" +
|
||||
" \"identifier\": [\n" +
|
||||
" {\n" +
|
||||
" \"system\": \"FOOORG:FOOSITE:coverage:planId\",\n" +
|
||||
" \"value\": \"0403-010101\"\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"beneficiary\": {\n" +
|
||||
" \"reference\": \"urn:uuid:d2a46176-8e15-405d-bbda-baea1a9dc7f3\"\n" +
|
||||
" }\n" +
|
||||
" },\n" +
|
||||
" \"request\": {\n" +
|
||||
" \"method\": \"PUT\",\n" +
|
||||
" \"url\": \"/Coverage?beneficiary=urn%3Auuid%3Ad2a46176-8e15-405d-bbda-baea1a9dc7f3&identifier=FOOORG%3AFOOSITE%3Acoverage%3AplanId%7C0403-010101\"\n" +
|
||||
" }\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"fullUrl\": \"urn:uuid:13f5da1a-6601-4c1a-82c9-41527be23fa0\",\n" +
|
||||
" \"resource\": {\n" +
|
||||
" \"resourceType\": \"Coverage\",\n" +
|
||||
" \"contained\": [\n" +
|
||||
" {\n" +
|
||||
" \"resourceType\": \"RelatedPerson\",\n" +
|
||||
" \"id\": \"1\",\n" +
|
||||
" \"name\": [\n" +
|
||||
" {\n" +
|
||||
" \"family\": \"SMITH\",\n" +
|
||||
" \"given\": [\n" +
|
||||
" \"FAKER\"\n" +
|
||||
" ]\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"resourceType\": \"Organization\",\n" +
|
||||
" \"id\": \"2\",\n" +
|
||||
" \"name\": \"MEDICAID\"\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"identifier\": [\n" +
|
||||
" {\n" +
|
||||
" \"system\": \"FOOORG:FOOSITE:coverage:planId\",\n" +
|
||||
" \"value\": \"0404-010101\"\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"policyHolder\": {\n" +
|
||||
" \"reference\": \"#1\"\n" +
|
||||
" },\n" +
|
||||
" \"beneficiary\": {\n" +
|
||||
" \"reference\": \"urn:uuid:d2a46176-8e15-405d-bbda-baea1a9dc7f3\"\n" +
|
||||
" },\n" +
|
||||
" \"payor\": [\n" +
|
||||
" {\n" +
|
||||
" \"reference\": \"#2\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" \"request\": {\n" +
|
||||
" \"method\": \"PUT\",\n" +
|
||||
" \"url\": \"/Coverage?beneficiary=urn%3Auuid%3Ad2a46176-8e15-405d-bbda-baea1a9dc7f3&identifier=FOOORG%3AFOOSITE%3Acoverage%3AplanId%7C0404-010101\"\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
"}";
|
||||
|
||||
Bundle inputBundle = myFhirCtx.newJsonParser().parseResource(Bundle.class, input);
|
||||
ourLog.info(myFhirCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(inputBundle));
|
||||
mySystemDao.transaction(mySrd, inputBundle);
|
||||
|
||||
inputBundle = myFhirCtx.newJsonParser().parseResource(Bundle.class, input);
|
||||
mySystemDao.transaction(mySrd, inputBundle);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testUniqueValuesAreIndexed_DateAndToken() {
|
||||
createUniqueBirthdateAndGenderSps();
|
||||
|
@ -423,6 +600,119 @@ public class FhirResourceDaoR4UniqueSearchParamTest extends BaseJpaR4Test {
|
|||
assertEquals("Patient?name=GIVEN2&organization=Organization%2FORG", uniques.get(2).getIndexString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUniqueValuesAreIndexed_StringAndReference_UsingConditional() {
|
||||
createUniqueNameAndManagingOrganizationSps();
|
||||
List<ResourceIndexedCompositeStringUnique> uniques;
|
||||
|
||||
Organization org = new Organization();
|
||||
org.setId("Organization/ORG");
|
||||
org.setName("ORG");
|
||||
myOrganizationDao.update(org);
|
||||
|
||||
Patient pt1 = new Patient();
|
||||
pt1.addName().setFamily("FAMILY1");
|
||||
pt1.setManagingOrganization(new Reference("Organization/ORG"));
|
||||
IIdType id1 = myPatientDao.update(pt1, "Patient?name=FAMILY1&organization.name=ORG").getId().toUnqualifiedVersionless();
|
||||
|
||||
uniques = myResourceIndexedCompositeStringUniqueDao.findAll();
|
||||
assertEquals(1, uniques.size());
|
||||
assertEquals("Patient/" + id1.getIdPart(), uniques.get(0).getResource().getIdDt().toUnqualifiedVersionless().getValue());
|
||||
assertEquals("Patient?name=FAMILY1&organization=Organization%2FORG", uniques.get(0).getIndexString());
|
||||
|
||||
// Again
|
||||
|
||||
pt1 = new Patient();
|
||||
pt1.addName().setFamily("FAMILY1");
|
||||
pt1.setManagingOrganization(new Reference("Organization/ORG"));
|
||||
id1 = myPatientDao.update(pt1, "Patient?name=FAMILY1&organization.name=ORG").getId().toUnqualifiedVersionless();
|
||||
|
||||
uniques = myResourceIndexedCompositeStringUniqueDao.findAll();
|
||||
assertEquals(1, uniques.size());
|
||||
assertEquals("Patient/" + id1.getIdPart(), uniques.get(0).getResource().getIdDt().toUnqualifiedVersionless().getValue());
|
||||
assertEquals("Patient?name=FAMILY1&organization=Organization%2FORG", uniques.get(0).getIndexString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUniqueValuesAreIndexed_StringAndReference_UsingConditionalInTransaction() {
|
||||
createUniqueNameAndManagingOrganizationSps();
|
||||
List<ResourceIndexedCompositeStringUnique> uniques;
|
||||
|
||||
Organization org = new Organization();
|
||||
org.setId("Organization/ORG");
|
||||
org.setName("ORG");
|
||||
myOrganizationDao.update(org);
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.setType(Bundle.BundleType.TRANSACTION);
|
||||
|
||||
String orgId = "urn:uuid:" + UUID.randomUUID().toString();
|
||||
org = new Organization();
|
||||
org.setName("ORG");
|
||||
bundle
|
||||
.addEntry()
|
||||
.setResource(org)
|
||||
.setFullUrl(orgId)
|
||||
.getRequest()
|
||||
.setMethod(Bundle.HTTPVerb.PUT)
|
||||
.setUrl("/Organization?name=ORG");
|
||||
|
||||
Patient pt1 = new Patient();
|
||||
pt1.addName().setFamily("FAMILY1");
|
||||
pt1.setManagingOrganization(new Reference(orgId));
|
||||
bundle
|
||||
.addEntry()
|
||||
.setResource(pt1)
|
||||
.getRequest()
|
||||
.setMethod(Bundle.HTTPVerb.PUT)
|
||||
.setUrl("/Patient?name=FAMILY1&organization=" + orgId.replace(":", "%3A"));
|
||||
|
||||
Bundle resp = mySystemDao.transaction(mySrd, bundle);
|
||||
|
||||
IIdType id1 = new IdType(resp.getEntry().get(1).getResponse().getLocation());
|
||||
|
||||
uniques = myResourceIndexedCompositeStringUniqueDao.findAll();
|
||||
assertEquals(1, uniques.size());
|
||||
assertEquals("Patient/" + id1.getIdPart(), uniques.get(0).getResource().getIdDt().toUnqualifiedVersionless().getValue());
|
||||
assertEquals("Patient?name=FAMILY1&organization=Organization%2FORG", uniques.get(0).getIndexString());
|
||||
|
||||
// Again
|
||||
|
||||
bundle = new Bundle();
|
||||
bundle.setType(Bundle.BundleType.TRANSACTION);
|
||||
|
||||
orgId = IdType.newRandomUuid().getValue();
|
||||
org = new Organization();
|
||||
org.setName("ORG");
|
||||
bundle
|
||||
.addEntry()
|
||||
.setResource(org)
|
||||
.setFullUrl(orgId)
|
||||
.getRequest()
|
||||
.setMethod(Bundle.HTTPVerb.PUT)
|
||||
.setUrl("/Organization?name=ORG");
|
||||
|
||||
pt1 = new Patient();
|
||||
pt1.addName().setFamily("FAMILY1");
|
||||
pt1.setManagingOrganization(new Reference(orgId));
|
||||
bundle
|
||||
.addEntry()
|
||||
.setResource(pt1)
|
||||
.getRequest()
|
||||
.setMethod(Bundle.HTTPVerb.PUT)
|
||||
.setUrl("/Patient?name=FAMILY1&organization=" + orgId);
|
||||
|
||||
resp = mySystemDao.transaction(mySrd, bundle);
|
||||
|
||||
id1 = new IdType(resp.getEntry().get(1).getResponse().getLocation());
|
||||
|
||||
uniques = myResourceIndexedCompositeStringUniqueDao.findAll();
|
||||
assertEquals(1, uniques.size());
|
||||
assertEquals("Patient/" + id1.getIdPart(), uniques.get(0).getResource().getIdDt().toUnqualifiedVersionless().getValue());
|
||||
assertEquals("Patient?name=FAMILY1&organization=Organization%2FORG", uniques.get(0).getIndexString());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUniqueValuesAreNotIndexedIfNotAllParamsAreFound_DateAndToken() {
|
||||
createUniqueBirthdateAndGenderSps();
|
||||
|
|
|
@ -281,11 +281,11 @@ public class RestHookTestDstu2Test extends BaseResourceProviderDstu2Test {
|
|||
}
|
||||
|
||||
public static void waitForQueueToDrain(BaseSubscriptionInterceptor theRestHookSubscriptionInterceptor) throws InterruptedException {
|
||||
ourLog.info("QUEUE HAS {} ITEMS", theRestHookSubscriptionInterceptor.getExecutorQueueForUnitTests().size());
|
||||
while (theRestHookSubscriptionInterceptor.getExecutorQueueForUnitTests().size() > 0) {
|
||||
ourLog.info("QUEUE HAS {} ITEMS", theRestHookSubscriptionInterceptor.getExecutorQueueSizeForUnitTests());
|
||||
while (theRestHookSubscriptionInterceptor.getExecutorQueueSizeForUnitTests() > 0) {
|
||||
Thread.sleep(50);
|
||||
}
|
||||
ourLog.info("QUEUE HAS {} ITEMS", theRestHookSubscriptionInterceptor.getExecutorQueueForUnitTests().size());
|
||||
ourLog.info("QUEUE HAS {} ITEMS", theRestHookSubscriptionInterceptor.getExecutorQueueSizeForUnitTests());
|
||||
}
|
||||
|
||||
private void waitForQueueToDrain() throws InterruptedException {
|
||||
|
|
|
@ -84,11 +84,11 @@ public class RestHookTestWithInterceptorRegisteredToDaoConfigR4Test extends Base
|
|||
}
|
||||
|
||||
private void waitForQueueToDrain() throws InterruptedException {
|
||||
ourLog.info("QUEUE HAS {} ITEMS", getRestHookSubscriptionInterceptor().getExecutorQueueForUnitTests().size());
|
||||
while (getRestHookSubscriptionInterceptor().getExecutorQueueForUnitTests().size() > 0) {
|
||||
ourLog.info("QUEUE HAS {} ITEMS", getRestHookSubscriptionInterceptor().getExecutorQueueSizeForUnitTests());
|
||||
while (getRestHookSubscriptionInterceptor().getExecutorQueueSizeForUnitTests() > 0) {
|
||||
Thread.sleep(250);
|
||||
}
|
||||
ourLog.info("QUEUE HAS {} ITEMS", getRestHookSubscriptionInterceptor().getExecutorQueueForUnitTests().size());
|
||||
ourLog.info("QUEUE HAS {} ITEMS", getRestHookSubscriptionInterceptor().getExecutorQueueSizeForUnitTests());
|
||||
}
|
||||
|
||||
private Observation sendObservation(String code, String system) throws InterruptedException {
|
||||
|
|
Loading…
Reference in New Issue