IDEMPIERE-5600 Add Core.getDefaultAnnotationBasedEventManager() API (#1698)
* IDEMPIERE-5600 Add Core.getDefaultAnnotationBasedEventManager() API * IDEMPIERE-5600 Add Core.getDefaultAnnotationBasedEventManager() API - minor refinement
This commit is contained in:
parent
7417e1ce3c
commit
859dd1a723
|
@ -1,4 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0" immediate="true" name="org.adempiere.base.DefaultAnnotationBasedEventManager">
|
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0" immediate="true" name="org.adempiere.base.DefaultAnnotationBasedEventManager">
|
||||||
|
<service>
|
||||||
|
<provide interface="org.adempiere.base.DefaultAnnotationBasedEventManager"/>
|
||||||
|
</service>
|
||||||
<implementation class="org.adempiere.base.DefaultAnnotationBasedEventManager"/>
|
<implementation class="org.adempiere.base.DefaultAnnotationBasedEventManager"/>
|
||||||
</scr:component>
|
</scr:component>
|
|
@ -27,6 +27,7 @@ package org.adempiere.base;
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.function.BiFunction;
|
import java.util.function.BiFunction;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
@ -73,9 +74,9 @@ public abstract class AnnotationBasedEventManager extends AnnotationBasedFactory
|
||||||
|
|
||||||
private static final CLogger s_log = CLogger.getCLogger(AnnotationBasedEventManager.class);
|
private static final CLogger s_log = CLogger.getCLogger(AnnotationBasedEventManager.class);
|
||||||
|
|
||||||
private IEventManager eventManager;
|
protected IEventManager eventManager;
|
||||||
private BundleContext bundleContext;
|
protected BundleContext bundleContext;
|
||||||
private List<EventHandler> handlers = new ArrayList<>();
|
protected List<EventHandler> handlers = new ArrayList<>();
|
||||||
|
|
||||||
private ServiceTracker<IEventManager, IEventManager> serviceTracker;
|
private ServiceTracker<IEventManager, IEventManager> serviceTracker;
|
||||||
|
|
||||||
|
@ -137,50 +138,84 @@ public abstract class AnnotationBasedEventManager extends AnnotationBasedFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform scan, discover and register of annotated classes
|
* Scan, discover and register annotated event delegate classes. <br/>
|
||||||
|
* The scan is asynchronous and return {@link CompletableFuture} to caller.
|
||||||
|
* If needed, caller can use the return {@link CompletableFuture} to wait for the scan to complete (using either get or join).
|
||||||
|
* @param context bundle context
|
||||||
|
* @param packageNames one or more package to scan
|
||||||
|
* @return CompletableFuture<List<EventHandler>>
|
||||||
*/
|
*/
|
||||||
protected void scan() {
|
public synchronized CompletableFuture<List<EventHandler>> scan(BundleContext context, String ...packageNames) {
|
||||||
long start = System.currentTimeMillis();
|
return scan(context, false, packageNames);
|
||||||
ClassLoader classLoader = bundleContext.getBundle().adapt(BundleWiring.class).getClassLoader();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan, discover and register annotated event delegate classes.
|
||||||
|
* @param context bundle context
|
||||||
|
* @param logScanDuration
|
||||||
|
* @param packageNames one or more package to scan
|
||||||
|
* @return CompletableFuture<List<EventHandler>>
|
||||||
|
*/
|
||||||
|
protected CompletableFuture<List<EventHandler>> scan(BundleContext context, boolean logScanDuration, String ...packageNames) {
|
||||||
|
long start = logScanDuration ? System.currentTimeMillis() : 0;
|
||||||
|
final CompletableFuture<List<EventHandler>> completable = new CompletableFuture<>();
|
||||||
|
ClassLoader classLoader = context.getBundle().adapt(BundleWiring.class).getClassLoader();
|
||||||
|
|
||||||
ClassGraph graph = new ClassGraph()
|
ClassGraph graph = new ClassGraph()
|
||||||
.enableAnnotationInfo()
|
.enableAnnotationInfo()
|
||||||
.overrideClassLoaders(classLoader)
|
.overrideClassLoaders(classLoader)
|
||||||
.disableNestedJarScanning()
|
.disableNestedJarScanning()
|
||||||
.disableModuleScanning()
|
.disableModuleScanning()
|
||||||
.acceptPackagesNonRecursive(getPackages());
|
.acceptPackagesNonRecursive(packageNames);
|
||||||
|
|
||||||
ScanResultProcessor scanResultProcessor = scanResult ->
|
ScanResultProcessor scanResultProcessor = scanResult ->
|
||||||
{
|
{
|
||||||
|
List<EventHandler> handlerList = new ArrayList<>();
|
||||||
for (ClassInfo classInfo : scanResult.getClassesWithAnnotation(EventTopicDelegate.class)) {
|
for (ClassInfo classInfo : scanResult.getClassesWithAnnotation(EventTopicDelegate.class)) {
|
||||||
if (classInfo.isAbstract())
|
if (classInfo.isAbstract())
|
||||||
continue;
|
continue;
|
||||||
|
EventHandler handler = null;
|
||||||
String className = classInfo.getName();
|
String className = classInfo.getName();
|
||||||
AnnotationInfo baseInfo = classInfo.getAnnotationInfo(EventTopicDelegate.class);
|
AnnotationInfo baseInfo = classInfo.getAnnotationInfo(EventTopicDelegate.class);
|
||||||
String filter = (String) baseInfo.getParameterValues().getValue("filter");
|
String filter = (String) baseInfo.getParameterValues().getValue("filter");
|
||||||
if (classInfo.hasAnnotation(ModelEventTopic.class)) {
|
if (classInfo.hasAnnotation(ModelEventTopic.class)) {
|
||||||
AnnotationInfo annotationInfo = classInfo.getAnnotationInfo(ModelEventTopic.class);
|
AnnotationInfo annotationInfo = classInfo.getAnnotationInfo(ModelEventTopic.class);
|
||||||
modelEventDelegate(classLoader, className, annotationInfo, filter);
|
handler = modelEventDelegate(classLoader, className, annotationInfo, filter);
|
||||||
} else if (classInfo.hasAnnotation(ImportEventTopic.class)) {
|
} else if (classInfo.hasAnnotation(ImportEventTopic.class)) {
|
||||||
AnnotationInfo annotationInfo = classInfo.getAnnotationInfo(ImportEventTopic.class);
|
AnnotationInfo annotationInfo = classInfo.getAnnotationInfo(ImportEventTopic.class);
|
||||||
importEventDelegate(classLoader, className, annotationInfo, filter);
|
handler = importEventDelegate(classLoader, className, annotationInfo, filter);
|
||||||
} else if (classInfo.hasAnnotation(ProcessEventTopic.class)) {
|
} else if (classInfo.hasAnnotation(ProcessEventTopic.class)) {
|
||||||
AnnotationInfo annotationInfo = classInfo.getAnnotationInfo(ProcessEventTopic.class);
|
AnnotationInfo annotationInfo = classInfo.getAnnotationInfo(ProcessEventTopic.class);
|
||||||
processEventDelegate(classLoader, className, annotationInfo, filter);
|
handler = processEventDelegate(classLoader, className, annotationInfo, filter);
|
||||||
} else {
|
} else {
|
||||||
simpleEventDelegate(classLoader, className, filter);
|
handler = simpleEventDelegate(classLoader, className, filter);
|
||||||
}
|
}
|
||||||
|
if (handler != null)
|
||||||
|
handlerList.add(handler);
|
||||||
}
|
}
|
||||||
long end = System.currentTimeMillis();
|
long end = System.currentTimeMillis();
|
||||||
s_log.info(() -> this.getClass().getSimpleName() + " loaded " + handlers.size() + " classes in "
|
if (logScanDuration)
|
||||||
+ ((end-start)/1000f) + "s");
|
s_log.info(() -> this.getClass().getSimpleName() + " loaded " + handlerList.size() + " classes in "
|
||||||
signalScanCompletion(true);
|
+ ((end-start)/1000f) + "s");
|
||||||
|
if (handlerList.size() > 0) {
|
||||||
|
synchronized (handlers) {
|
||||||
|
handlers.addAll(handlerList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completable.complete(handlerList);
|
||||||
};
|
};
|
||||||
|
|
||||||
graph.scanAsync(getExecutorService(), getMaxThreads(), scanResultProcessor, getScanFailureHandler());
|
graph.scanAsync(getExecutorService(), getMaxThreads(), scanResultProcessor, getScanFailureHandler());
|
||||||
|
return completable;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Perform asynchronous scan, discover and register of annotated event delegate classes.
|
||||||
|
*/
|
||||||
|
protected void scan() {
|
||||||
|
scan(bundleContext, true, getPackages());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void simpleEventDelegate(ClassLoader classLoader, String className, String filter) {
|
private EventHandler simpleEventDelegate(ClassLoader classLoader, String className, String filter) {
|
||||||
try {
|
try {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Class<? extends EventDelegate> delegateClass = (Class<? extends EventDelegate>) classLoader.loadClass(className);
|
Class<? extends EventDelegate> delegateClass = (Class<? extends EventDelegate>) classLoader.loadClass(className);
|
||||||
|
@ -189,15 +224,16 @@ public abstract class AnnotationBasedEventManager extends AnnotationBasedFactory
|
||||||
SimpleEventHandler handler = new SimpleEventHandler(delegateClass, supplier);
|
SimpleEventHandler handler = new SimpleEventHandler(delegateClass, supplier);
|
||||||
if (!Util.isEmpty(filter, true))
|
if (!Util.isEmpty(filter, true))
|
||||||
handler.setFilter(filter);
|
handler.setFilter(filter);
|
||||||
handlers.add(handler);
|
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
||||||
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
return handler;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (s_log.isLoggable(Level.INFO))
|
if (s_log.isLoggable(Level.INFO))
|
||||||
s_log.log(Level.INFO, e.getMessage(), e);
|
s_log.log(Level.INFO, e.getMessage(), e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processEventDelegate(ClassLoader classLoader, String className, AnnotationInfo annotationInfo, String filter) {
|
private EventHandler processEventDelegate(ClassLoader classLoader, String className, AnnotationInfo annotationInfo, String filter) {
|
||||||
try {
|
try {
|
||||||
String processUUID = (String) annotationInfo.getParameterValues().getValue("processUUID");
|
String processUUID = (String) annotationInfo.getParameterValues().getValue("processUUID");
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
@ -207,15 +243,16 @@ public abstract class AnnotationBasedEventManager extends AnnotationBasedFactory
|
||||||
ProcessEventHandler handler = new ProcessEventHandler(delegateClass, processUUID, supplier);
|
ProcessEventHandler handler = new ProcessEventHandler(delegateClass, processUUID, supplier);
|
||||||
if (!Util.isEmpty(filter, true))
|
if (!Util.isEmpty(filter, true))
|
||||||
handler.setFilter(filter);
|
handler.setFilter(filter);
|
||||||
handlers.add(handler);
|
|
||||||
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
||||||
|
return handler;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (s_log.isLoggable(Level.INFO))
|
if (s_log.isLoggable(Level.INFO))
|
||||||
s_log.log(Level.INFO, e.getMessage(), e);
|
s_log.log(Level.INFO, e.getMessage(), e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void importEventDelegate(ClassLoader classLoader, String className, AnnotationInfo annotationInfo, String filter) {
|
private EventHandler importEventDelegate(ClassLoader classLoader, String className, AnnotationInfo annotationInfo, String filter) {
|
||||||
try {
|
try {
|
||||||
String importTableName = (String) annotationInfo.getParameterValues().getValue("importTableName");
|
String importTableName = (String) annotationInfo.getParameterValues().getValue("importTableName");
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
@ -225,15 +262,16 @@ public abstract class AnnotationBasedEventManager extends AnnotationBasedFactory
|
||||||
ImportEventHandler handler = new ImportEventHandler(delegateClass, importTableName, supplier);
|
ImportEventHandler handler = new ImportEventHandler(delegateClass, importTableName, supplier);
|
||||||
if (!Util.isEmpty(filter, true))
|
if (!Util.isEmpty(filter, true))
|
||||||
handler.setFilter(filter);
|
handler.setFilter(filter);
|
||||||
handlers.add(handler);
|
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
||||||
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
return handler;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (s_log.isLoggable(Level.INFO))
|
if (s_log.isLoggable(Level.INFO))
|
||||||
s_log.log(Level.INFO, e.getMessage(), e);
|
s_log.log(Level.INFO, e.getMessage(), e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void modelEventDelegate(ClassLoader classLoader, String className, AnnotationInfo annotationInfo, String filter) {
|
private EventHandler modelEventDelegate(ClassLoader classLoader, String className, AnnotationInfo annotationInfo, String filter) {
|
||||||
try {
|
try {
|
||||||
AnnotationClassRef classRef = (AnnotationClassRef) annotationInfo.getParameterValues().getValue("modelClass");
|
AnnotationClassRef classRef = (AnnotationClassRef) annotationInfo.getParameterValues().getValue("modelClass");
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
@ -246,11 +284,12 @@ public abstract class AnnotationBasedEventManager extends AnnotationBasedFactory
|
||||||
ModelEventHandler<?> handler = new ModelEventHandler(modelClass, delegateClass, supplier);
|
ModelEventHandler<?> handler = new ModelEventHandler(modelClass, delegateClass, supplier);
|
||||||
if (!Util.isEmpty(filter, true))
|
if (!Util.isEmpty(filter, true))
|
||||||
handler.setFilter(filter);
|
handler.setFilter(filter);
|
||||||
handlers.add(handler);
|
|
||||||
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
eventManager.register(handler.getTopics(), handler.getFilter(), handler);
|
||||||
|
return handler;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (s_log.isLoggable(Level.INFO))
|
if (s_log.isLoggable(Level.INFO))
|
||||||
s_log.log(Level.INFO, e.getMessage(), e);
|
s_log.log(Level.INFO, e.getMessage(), e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1076,4 +1076,15 @@ public class Core {
|
||||||
//fall back, should not reach here
|
//fall back, should not reach here
|
||||||
return new DefaultTaxLookup();
|
return new DefaultTaxLookup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {@link DefaultAnnotationBasedEventManager}
|
||||||
|
*/
|
||||||
|
public static DefaultAnnotationBasedEventManager getDefaultAnnotationBasedEventManager() {
|
||||||
|
IServiceReferenceHolder<DefaultAnnotationBasedEventManager> serviceReference = Service.locator().locate(DefaultAnnotationBasedEventManager.class).getServiceReference();
|
||||||
|
if (serviceReference != null) {
|
||||||
|
return serviceReference.getService();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ package org.adempiere.base;
|
||||||
|
|
||||||
import org.osgi.service.component.annotations.Component;
|
import org.osgi.service.component.annotations.Component;
|
||||||
|
|
||||||
@Component(immediate = true, service = {})
|
@Component(immediate = true, service = {DefaultAnnotationBasedEventManager.class})
|
||||||
public class DefaultAnnotationBasedEventManager extends AnnotationBasedEventManager {
|
public class DefaultAnnotationBasedEventManager extends AnnotationBasedEventManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
/***********************************************************************
|
||||||
|
* This file is part of iDempiere ERP Open Source *
|
||||||
|
* http://www.idempiere.org *
|
||||||
|
* *
|
||||||
|
* Copyright (C) Contributors *
|
||||||
|
* *
|
||||||
|
* This program is free software; you can redistribute it and/or *
|
||||||
|
* modify it under the terms of the GNU General Public License *
|
||||||
|
* as published by the Free Software Foundation; either version 2 *
|
||||||
|
* of the License, or (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* This program is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the GNU General Public License *
|
||||||
|
* along with this program; if not, write to the Free Software *
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, *
|
||||||
|
* MA 02110-1301, USA. *
|
||||||
|
* *
|
||||||
|
* Contributors: *
|
||||||
|
* - hengsin *
|
||||||
|
**********************************************************************/
|
||||||
|
package org.idempiere.test.event;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import org.adempiere.base.Core;
|
||||||
|
import org.adempiere.base.DefaultAnnotationBasedEventManager;
|
||||||
|
import org.compiere.model.MTest;
|
||||||
|
import org.compiere.util.Env;
|
||||||
|
import org.idempiere.test.AbstractTestCase;
|
||||||
|
import org.idempiere.test.TestActivator;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.parallel.Isolated;
|
||||||
|
import org.osgi.service.event.EventHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author hengsin
|
||||||
|
*/
|
||||||
|
@Isolated
|
||||||
|
public class EventDelegateAnnotationTest extends AbstractTestCase {
|
||||||
|
|
||||||
|
public EventDelegateAnnotationTest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAnnotatedEventDelegate() {
|
||||||
|
DefaultAnnotationBasedEventManager mgr = Core.getDefaultAnnotationBasedEventManager();
|
||||||
|
CompletableFuture<List<EventHandler>> completable = mgr.scan(TestActivator.context, MTestEventDelegate.class.getPackageName());
|
||||||
|
completable.join();
|
||||||
|
|
||||||
|
String desc = "test";
|
||||||
|
MTest mtest = new MTest(Env.getCtx(), 0, getTrxName());
|
||||||
|
mtest.setName("testAnnotatedEventDelegate");
|
||||||
|
mtest.setDescription(desc);
|
||||||
|
mtest.saveEx();
|
||||||
|
|
||||||
|
assertEquals(desc + "MTestEventDelegate", mtest.getDescription(), "MTestEventDelegate not handling before new event as expected");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
/***********************************************************************
|
||||||
|
* This file is part of iDempiere ERP Open Source *
|
||||||
|
* http://www.idempiere.org *
|
||||||
|
* *
|
||||||
|
* Copyright (C) Contributors *
|
||||||
|
* *
|
||||||
|
* This program is free software; you can redistribute it and/or *
|
||||||
|
* modify it under the terms of the GNU General Public License *
|
||||||
|
* as published by the Free Software Foundation; either version 2 *
|
||||||
|
* of the License, or (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* This program is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the GNU General Public License *
|
||||||
|
* along with this program; if not, write to the Free Software *
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, *
|
||||||
|
* MA 02110-1301, USA. *
|
||||||
|
* *
|
||||||
|
* Contributors: *
|
||||||
|
* - hengsin *
|
||||||
|
**********************************************************************/
|
||||||
|
package org.idempiere.test.event;
|
||||||
|
|
||||||
|
import org.adempiere.base.annotation.EventTopicDelegate;
|
||||||
|
import org.adempiere.base.annotation.ModelEventTopic;
|
||||||
|
import org.adempiere.base.event.annotations.ModelEventDelegate;
|
||||||
|
import org.adempiere.base.event.annotations.po.BeforeChange;
|
||||||
|
import org.adempiere.base.event.annotations.po.BeforeNew;
|
||||||
|
import org.compiere.model.MTest;
|
||||||
|
import org.osgi.service.event.Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author hengsin
|
||||||
|
*/
|
||||||
|
@EventTopicDelegate
|
||||||
|
@ModelEventTopic(modelClass = MTest.class)
|
||||||
|
public class MTestEventDelegate extends ModelEventDelegate<MTest> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param po
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
public MTestEventDelegate(MTest po, Event event) {
|
||||||
|
super(po, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeChange
|
||||||
|
@BeforeNew
|
||||||
|
public void onBeforeChange() {
|
||||||
|
String desc = getModel().getDescription();
|
||||||
|
if (desc != null)
|
||||||
|
desc = desc + "MTestEventDelegate";
|
||||||
|
else
|
||||||
|
desc = "MTestEventDelegate";
|
||||||
|
getModel().setDescription(desc);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue