diff --git a/src/main/java/com/netgrif/application/engine/archive/ZipService.java b/src/main/java/com/netgrif/application/engine/archive/ZipService.java new file mode 100644 index 00000000000..1254ecd5909 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/archive/ZipService.java @@ -0,0 +1,146 @@ +package com.netgrif.application.engine.archive; + +import com.netgrif.application.engine.archive.interfaces.IArchiveService; +import com.netgrif.application.engine.files.IStorageResolverService; +import com.netgrif.application.engine.files.interfaces.IStorageService; +import com.netgrif.application.engine.petrinet.domain.dataset.StorageField; +import com.netgrif.application.engine.workflow.domain.CaseExportFiles; +import com.netgrif.application.engine.workflow.domain.StorageFieldWithFileNames; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ZipService implements IArchiveService { + + private final IStorageResolverService storageResolverService; + + @Override + public void pack(String archivePath, CaseExportFiles caseExportFiles, String... additionalFiles) throws IOException { + FileOutputStream fos = new FileOutputStream(archivePath); + this.pack(fos, caseExportFiles, additionalFiles); + fos.close(); + } + + @Override + public void pack(OutputStream archiveStream, CaseExportFiles caseExportFiles, String... additionalFiles) throws IOException { + ZipOutputStream zipStream = new ZipOutputStream(archiveStream); + for (String caseId : caseExportFiles.getCaseIds()) { + for (StorageFieldWithFileNames fieldWithFileNames : caseExportFiles.getFieldsOfCase(caseId)) { + StorageField field = fieldWithFileNames.getField(); + IStorageService storageService = storageResolverService.resolve(field.getStorageType()); + for (String fileName : fieldWithFileNames.getFileNames()) { + String filePath = storageService.getPath(caseId, field.getStringId(), fileName); + InputStream fis = storageService.get(field, filePath); + String newFileName = Paths.get(caseId, field.getStringId(), fileName).getFileName().toString(); + createAndWriteZipEntry(newFileName, zipStream, fis); + fis.close(); + } + } + } + for (String filePath : additionalFiles) { + InputStream fis = new FileInputStream(filePath); + createAndWriteZipEntry(Paths.get(filePath).getFileName().toString(), zipStream, fis); + fis.close(); + } + zipStream.close(); + } + + @Override + public OutputStream createArchive(CaseExportFiles caseExportFiles) throws IOException { + return createArchive(Files.createTempFile(UUID.randomUUID().toString(), ".zip").toString(), caseExportFiles); + } + + @Override + public OutputStream createArchive(String archivePath, CaseExportFiles caseExportFiles) throws IOException { + File zipFile = new File(archivePath); + return zipFiles(zipFile, caseExportFiles); + } + + private OutputStream zipFiles(File zipFile, CaseExportFiles caseExportFiles) throws IOException { + FileOutputStream fos = new FileOutputStream(zipFile); + this.pack(fos, caseExportFiles); + return fos; + } + + @Override + public void append(OutputStream archiveStream, String... filePaths) throws IOException { +// todo implement + } + + private void createAndWriteZipEntry(String fileName, ZipOutputStream zipStream, InputStream fis) throws IOException { +// source https://www.baeldung.com/java-compress-and-uncompress + ZipEntry zipEntry = new ZipEntry(fileName); + zipStream.putNextEntry(zipEntry); + byte[] bytes = new byte[1024]; + int length; + while ((length = fis.read(bytes)) >= 0) { + zipStream.write(bytes, 0, length); + } + } + + @Override + public String unpack(String archivePath, String outputPath) throws IOException { + return this.unpack(new FileInputStream(archivePath), outputPath); + } + + @Override + public String unpack(InputStream archiveStream, String outputPath) throws IOException { +// source: https://www.baeldung.com/java-compress-and-uncompress + File destDir = new File(outputPath); + + byte[] buffer = new byte[1024]; + ZipInputStream zis = new ZipInputStream(archiveStream); + ZipEntry zipEntry = zis.getNextEntry(); + while (zipEntry != null) { + File newFile = newFile(destDir, zipEntry); + if (zipEntry.isDirectory()) { + if (!newFile.isDirectory() && !newFile.mkdirs()) { + throw new IOException("Failed to create directory " + newFile); + } + } else { + // fix for Windows-created archives + File parent = newFile.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Failed to create directory " + parent); + } + + // write file content + FileOutputStream fos = new FileOutputStream(newFile); + int len; + while ((len = zis.read(buffer)) > 0) { + fos.write(buffer, 0, len); + } + fos.close(); + } + zipEntry = zis.getNextEntry(); + } + + zis.closeEntry(); + zis.close(); + return destDir.getPath(); + } + + private File newFile(File destinationDir, ZipEntry zipEntry) throws IOException { + File destFile = new File(destinationDir, zipEntry.getName()); + + String destDirPath = destinationDir.getCanonicalPath(); + String destFilePath = destFile.getCanonicalPath(); + + if (!destFilePath.startsWith(destDirPath + File.separator)) { + throw new IOException("Entry is outside of the target dir: " + zipEntry.getName()); + } + + return destFile; + } +} diff --git a/src/main/java/com/netgrif/application/engine/archive/interfaces/IArchiveService.java b/src/main/java/com/netgrif/application/engine/archive/interfaces/IArchiveService.java new file mode 100644 index 00000000000..8ab4d1e3d2a --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/archive/interfaces/IArchiveService.java @@ -0,0 +1,25 @@ +package com.netgrif.application.engine.archive.interfaces; + +import com.netgrif.application.engine.workflow.domain.CaseExportFiles; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public interface IArchiveService { + + void pack(String archivePath, CaseExportFiles caseExportFiles, String... additionalFiles) throws IOException; + + void pack(OutputStream archiveStream, CaseExportFiles caseExportFiles, String... additionalFiles) throws IOException; + + OutputStream createArchive(CaseExportFiles caseExportFiles) throws IOException; + + OutputStream createArchive(String archivePath, CaseExportFiles caseExportFiles) throws IOException; + + void append(OutputStream archiveStream, String... filePaths) throws IOException; + + String unpack(String archivePath, String outputPath) throws IOException; + + String unpack(InputStream archiveStream, String outputPath) throws IOException; +} diff --git a/src/main/java/com/netgrif/application/engine/configuration/PrototypesConfiguration.java b/src/main/java/com/netgrif/application/engine/configuration/PrototypesConfiguration.java index f5ec750ded1..150fcbd69fc 100644 --- a/src/main/java/com/netgrif/application/engine/configuration/PrototypesConfiguration.java +++ b/src/main/java/com/netgrif/application/engine/configuration/PrototypesConfiguration.java @@ -10,10 +10,11 @@ import com.netgrif.application.engine.pdf.generator.service.interfaces.IPdfGenerator; import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate; import com.netgrif.application.engine.workflow.domain.FileStorageConfiguration; +import com.netgrif.application.engine.workflow.service.CaseExporter; +import com.netgrif.application.engine.workflow.service.CaseImporter; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Scope; @Configuration @@ -60,4 +61,15 @@ public IPdfDrawer pdfDrawer() { public UserResourceAssembler userResourceAssembler() { return new UserResourceAssembler(); } + + @Bean("caseExporter") + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + public CaseExporter caseExporter() { + return new CaseExporter(); + } + @Bean("caseImporter") + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + public CaseImporter caseImporter() { + return new CaseImporter(); + } } \ No newline at end of file diff --git a/src/main/java/com/netgrif/application/engine/configuration/properties/CaseExportProperties.java b/src/main/java/com/netgrif/application/engine/configuration/properties/CaseExportProperties.java new file mode 100644 index 00000000000..86670f623fc --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/configuration/properties/CaseExportProperties.java @@ -0,0 +1,15 @@ +package com.netgrif.application.engine.configuration.properties; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Data +@Component +@ConfigurationProperties(prefix = "nae.case.export") +public class CaseExportProperties { + + private String fileName; +} \ No newline at end of file diff --git a/src/main/java/com/netgrif/application/engine/configuration/properties/SchemaProperties.java b/src/main/java/com/netgrif/application/engine/configuration/properties/SchemaProperties.java new file mode 100644 index 00000000000..55fa01d4282 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/configuration/properties/SchemaProperties.java @@ -0,0 +1,16 @@ +package com.netgrif.application.engine.configuration.properties; + + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Data +@Component +@ConfigurationProperties(prefix = "nae.schema") +public class SchemaProperties { + + private String location; +} diff --git a/src/main/java/com/netgrif/application/engine/importer/service/FieldFactory.java b/src/main/java/com/netgrif/application/engine/importer/service/FieldFactory.java index 4ac4a03c32c..481488da0e6 100644 --- a/src/main/java/com/netgrif/application/engine/importer/service/FieldFactory.java +++ b/src/main/java/com/netgrif/application/engine/importer/service/FieldFactory.java @@ -12,6 +12,7 @@ import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.runner.Expression; import com.netgrif.application.engine.petrinet.domain.dataset.logic.validation.DynamicValidation; import com.netgrif.application.engine.petrinet.domain.views.View; +import com.netgrif.application.engine.utils.ImporterUtils; import com.netgrif.application.engine.workflow.domain.Case; import com.netgrif.application.engine.workflow.domain.DataField; import com.netgrif.application.engine.workflow.service.interfaces.IDataValidationExpressionEvaluator; @@ -276,13 +277,13 @@ Field getField(Data data, Importer importer) throws IllegalArgumentException, Mi if (data.getValid() != null) { List list = data.getValid(); for (Valid item : list) { - field.addValidation(makeValidation(item.getValue(), null, item.isDynamic())); + field.addValidation(ImporterUtils.makeValidation(item.getValue(), null, item.isDynamic())); } } if (data.getValidations() != null) { List list = data.getValidations().getValidation(); for (com.netgrif.application.engine.importer.model.Validation item : list) { - field.addValidation(makeValidation(item.getExpression().getValue(), importer.toI18NString(item.getMessage()), item.getExpression().isDynamic())); + field.addValidation(ImporterUtils.makeValidation(item.getExpression().getValue(), importer.toI18NString(item.getMessage()), item.getExpression().isDynamic())); } } @@ -318,10 +319,6 @@ private StringCollectionField buildStringCollectionField(Data data, Importer imp return field; } - private com.netgrif.application.engine.petrinet.domain.dataset.logic.validation.Validation makeValidation(String rule, I18nString message, boolean dynamic) { - return dynamic ? new DynamicValidation(rule, message) : new com.netgrif.application.engine.petrinet.domain.dataset.logic.validation.Validation(rule, message); - } - private TaskField buildTaskField(Data data, List transitions) { TaskField field = new TaskField(); setDefaultValues(field, data, defaultValues -> { diff --git a/src/main/java/com/netgrif/application/engine/importer/service/Importer.java b/src/main/java/com/netgrif/application/engine/importer/service/Importer.java index 7c6551bd47d..fd640ec643a 100644 --- a/src/main/java/com/netgrif/application/engine/importer/service/Importer.java +++ b/src/main/java/com/netgrif/application/engine/importer/service/Importer.java @@ -32,6 +32,7 @@ import com.netgrif.application.engine.petrinet.service.ArcFactory; import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService; import com.netgrif.application.engine.petrinet.service.interfaces.IProcessRoleService; +import com.netgrif.application.engine.utils.ImporterUtils; import com.netgrif.application.engine.workflow.domain.FileStorageConfiguration; import com.netgrif.application.engine.workflow.domain.ProcessResourceId; import com.netgrif.application.engine.workflow.domain.triggers.Trigger; @@ -220,7 +221,7 @@ protected Optional createPetriNet() throws MissingPetriNetMetaDataExce net.setDefaultCaseName(toI18NString(document.getCaseName())); } if (document.getTags() != null) { - net.setTags(this.buildTagsMap(document.getTags().getTag())); + net.setTags(ImporterUtils.buildTagsMap(document.getTags().getTag())); } return Optional.of(net); @@ -496,7 +497,7 @@ protected void createTransition(com.netgrif.application.engine.importer.model.Tr transition.setTitle(importTransition.getLabel() != null ? toI18NString(importTransition.getLabel()) : new I18nString("")); transition.setPosition(importTransition.getX(), importTransition.getY()); if (importTransition.getTags() != null) { - transition.setTags(this.buildTagsMap(importTransition.getTags().getTag())); + transition.setTags(ImporterUtils.buildTagsMap(importTransition.getTags().getTag())); } if (importTransition.getLayout() != null) { @@ -1322,14 +1323,4 @@ protected void setMetaData() throws MissingPetriNetMetaDataException { if (!missingMetaData.isEmpty()) throw new MissingPetriNetMetaDataException(missingMetaData); } - - protected Map buildTagsMap(List tagsList) { - Map tags = new HashMap<>(); - if (tagsList != null) { - tagsList.forEach(tag -> { - tags.put(tag.getKey(), tag.getValue()); - }); - } - return tags; - } } diff --git a/src/main/java/com/netgrif/application/engine/utils/ImporterUtils.java b/src/main/java/com/netgrif/application/engine/utils/ImporterUtils.java new file mode 100644 index 00000000000..dd08ff63add --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/utils/ImporterUtils.java @@ -0,0 +1,27 @@ +package com.netgrif.application.engine.utils; + +import com.netgrif.application.engine.importer.model.Tag; +import com.netgrif.application.engine.petrinet.domain.I18nString; +import com.netgrif.application.engine.petrinet.domain.dataset.logic.validation.DynamicValidation; +import com.netgrif.application.engine.petrinet.domain.dataset.logic.validation.Validation; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ImporterUtils { + + public static Map buildTagsMap(List tagsList) { + Map tags = new HashMap<>(); + if (tagsList != null) { + tagsList.forEach(tag -> { + tags.put(tag.getKey(), tag.getValue()); + }); + } + return tags; + } + + public static Validation makeValidation(String rule, I18nString message, boolean dynamic) { + return dynamic ? new DynamicValidation(rule, message) : new Validation(rule, message); + } +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/domain/Case.java b/src/main/java/com/netgrif/application/engine/workflow/domain/Case.java index d448dc4ddaf..d78f604a34f 100644 --- a/src/main/java/com/netgrif/application/engine/workflow/domain/Case.java +++ b/src/main/java/com/netgrif/application/engine/workflow/domain/Case.java @@ -177,8 +177,12 @@ protected Case() { } public Case(PetriNet petriNet) { + this(petriNet,new ProcessResourceId(petriNet.getObjectId())); + } + + public Case(PetriNet petriNet, ProcessResourceId _id) { this(); - this._id = new ProcessResourceId(petriNet.getObjectId()); + this._id = _id; petriNetObjectId = petriNet.getObjectId(); processIdentifier = petriNet.getIdentifier(); this.petriNet = petriNet; diff --git a/src/main/java/com/netgrif/application/engine/workflow/domain/CaseExportFiles.java b/src/main/java/com/netgrif/application/engine/workflow/domain/CaseExportFiles.java new file mode 100644 index 00000000000..0b235720b4d --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/domain/CaseExportFiles.java @@ -0,0 +1,25 @@ +package com.netgrif.application.engine.workflow.domain; + +import java.util.*; + +public class CaseExportFiles { + + private final Map> caseFileMapping = new HashMap<>(); + + public void addFieldFilenames(String caseId, StorageFieldWithFileNames storageField) { + List emptyFieldMapping = new ArrayList<>(); + List fieldMapping = caseFileMapping.putIfAbsent(caseId, emptyFieldMapping); + if (fieldMapping == null) { + fieldMapping = emptyFieldMapping; + } + fieldMapping.add(storageField); + } + + public Set getCaseIds() { + return caseFileMapping.keySet(); + } + + public List getFieldsOfCase(String caseId) { + return caseFileMapping.get(caseId); + } +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/domain/StorageFieldWithFileNames.java b/src/main/java/com/netgrif/application/engine/workflow/domain/StorageFieldWithFileNames.java new file mode 100644 index 00000000000..1a7864156de --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/domain/StorageFieldWithFileNames.java @@ -0,0 +1,18 @@ +package com.netgrif.application.engine.workflow.domain; + +import com.netgrif.application.engine.petrinet.domain.dataset.StorageField; +import lombok.Data; + +import java.util.Set; + +@Data +public class StorageFieldWithFileNames { + + private StorageField field; + private Set fileNames; + + public StorageFieldWithFileNames(StorageField field, Set fileNames) { + this.field = field; + this.fileNames = fileNames; + } +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/exceptions/ImportXmlFileMissingException.java b/src/main/java/com/netgrif/application/engine/workflow/exceptions/ImportXmlFileMissingException.java new file mode 100644 index 00000000000..2d1fbb56c92 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/exceptions/ImportXmlFileMissingException.java @@ -0,0 +1,7 @@ +package com.netgrif.application.engine.workflow.exceptions; + +public class ImportXmlFileMissingException extends Exception { + public ImportXmlFileMissingException(String message) { + super(message); + } +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/service/CaseExporter.java b/src/main/java/com/netgrif/application/engine/workflow/service/CaseExporter.java new file mode 100644 index 00000000000..b0493cf99ae --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/service/CaseExporter.java @@ -0,0 +1,518 @@ +package com.netgrif.application.engine.workflow.service; + +import com.netgrif.application.engine.configuration.properties.SchemaProperties; +import com.netgrif.application.engine.importer.model.*; +import com.netgrif.application.engine.importer.model.Properties; +import com.netgrif.application.engine.petrinet.domain.I18nString; +import com.netgrif.application.engine.petrinet.domain.dataset.*; +import com.netgrif.application.engine.petrinet.domain.dataset.logic.FieldBehavior; +import com.netgrif.application.engine.workflow.domain.triggers.AutoTrigger; +import com.netgrif.application.engine.workflow.domain.triggers.TimeTrigger; +import com.netgrif.application.engine.workflow.domain.triggers.Trigger; +import com.netgrif.application.engine.workflow.service.interfaces.ITaskService; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Marshaller; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.OutputStream; +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; + +@Slf4j +public class CaseExporter { + + @Autowired + private ITaskService taskService; + + @Autowired + private SchemaProperties properties; + + private final ObjectFactory objectFactory = new ObjectFactory(); + + private OutputStream outputStream; + private com.netgrif.application.engine.workflow.domain.Case caseToExport; + private Case xmlCase; + private HashMap translations; + + // todo custom error handling? + public void exportCases(Collection casesToExport, OutputStream outputStream) throws RuntimeException { + this.outputStream = outputStream; + + Cases xmlCases = objectFactory.createCases(); + casesToExport.forEach(toExport -> { + this.caseToExport = toExport; + this.translations = new HashMap<>(); + xmlCases.getCase().add(exportCase()); + }); + + try { + marshallCase(xmlCases); + } catch (JAXBException e) { + log.error("Error occured during masrhalling of cases", e); + throw new RuntimeException(e); + } + } + + private void marshallCase(com.netgrif.application.engine.importer.model.Cases caseToExport) throws JAXBException { + JAXBContext jaxbContext = JAXBContext.newInstance(com.netgrif.application.engine.importer.model.Cases.class); + Marshaller marshaller = jaxbContext.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); + marshaller.setProperty(Marshaller.JAXB_NO_NAMESPACE_SCHEMA_LOCATION, properties.getLocation()); + marshaller.marshal(caseToExport, this.outputStream); + } + + private Case exportCase() { + this.xmlCase = objectFactory.createCase(); + exportCaseMetadata(caseToExport); + exportTasks(); + exportDataFields(); + this.xmlCase.getI18N().addAll(this.translations.values()); + return xmlCase; + } + + private void exportCaseMetadata(com.netgrif.application.engine.workflow.domain.Case caseToExport) { + this.xmlCase.setId(caseToExport.getStringId()); +// todo should the whole object be exported? is id specific enough? should email be exported instead of id? + this.xmlCase.setAuthor(caseToExport.getAuthor().getId()); + this.xmlCase.setColor(caseToExport.getColor()); + this.xmlCase.setProcessVersion(caseToExport.getPetriNet().getVersion().toString()); + this.xmlCase.setProcessIdentifier(caseToExport.getProcessIdentifier()); + this.xmlCase.setVisualId(caseToExport.getVisualId()); + this.xmlCase.setUriNodeId(caseToExport.getUriNodeId()); + this.xmlCase.setTags(exportTags(caseToExport.getTags())); + this.xmlCase.setTitle(caseToExport.getTitle()); + this.xmlCase.setCreationDate(exportLocalDateTime(caseToExport.getCreationDate())); + this.xmlCase.setLastModified(exportLocalDateTime(caseToExport.getLastModified())); + this.xmlCase.setViewRoles(exportCollectionOfStrings(caseToExport.getViewRoles())); + this.xmlCase.setViewUserRefs(exportCollectionOfStrings(caseToExport.getNegativeViewUsers())); + this.xmlCase.setViewUsers(exportCollectionOfStrings(caseToExport.getNegativeViewUsers())); + this.xmlCase.setNegativeViewUsers(exportCollectionOfStrings(caseToExport.getNegativeViewUsers())); + this.xmlCase.setActivePlaces(exportMapXsdType(caseToExport.getActivePlaces())); + this.xmlCase.setConsumedTokens(exportMapXsdType(caseToExport.getConsumedTokens())); + this.xmlCase.setUsers(exportPermissions(caseToExport.getUsers())); + } + + private void exportTasks() { + List tasksToExport = caseToExport.getTasks().stream() + .map(taskPair -> taskService.findOne(taskPair.getTask())) + .toList(); + tasksToExport.forEach(taskToExport -> this.xmlCase.getTask().add(exportTask(taskToExport))); + } + + private Task exportTask(com.netgrif.application.engine.workflow.domain.Task taskToExport) { + Task xmlTask = objectFactory.createTask(); + xmlTask.setId(taskToExport.getStringId()); + xmlTask.setTransitionId(taskToExport.getTransitionId()); + xmlTask.setTitle(exportI18NString(taskToExport.getTitle())); + xmlTask.setPriority(exportInteger(taskToExport.getPriority())); + xmlTask.setUserId(taskToExport.getUserId()); + xmlTask.setStartDate(exportLocalDateTime(taskToExport.getStartDate())); + xmlTask.setFinishDate(exportLocalDateTime(taskToExport.getFinishDate())); + xmlTask.setFinishedBy(taskToExport.getFinishedBy()); + xmlTask.setTransactionId(taskToExport.getTransactionId()); + xmlTask.setIcon(taskToExport.getIcon()); + xmlTask.setAssignPolicy(AssignPolicy.fromValue(taskToExport.getAssignPolicy().toString().toLowerCase())); + xmlTask.setDataFocusPolicy(DataFocusPolicy.fromValue(taskToExport.getDataFocusPolicy().toString().toLowerCase())); + xmlTask.setFinishPolicy(FinishPolicy.fromValue(taskToExport.getFinishPolicy().toString().toLowerCase())); + xmlTask.setTags(exportTags(taskToExport.getTags())); + xmlTask.setViewRoles(exportCollectionOfStrings(taskToExport.getViewRoles())); + xmlTask.setViewUserRefs(exportCollectionOfStrings(taskToExport.getNegativeViewUsers())); + xmlTask.setViewUsers(exportCollectionOfStrings(taskToExport.getNegativeViewUsers())); + xmlTask.setNegativeViewRoles(exportCollectionOfStrings(taskToExport.getNegativeViewRoles())); + xmlTask.setNegativeViewUsers(exportCollectionOfStrings(taskToExport.getNegativeViewUsers())); + xmlTask.setImmediateDataFields(exportCollectionOfStrings(taskToExport.getImmediateDataFields())); + xmlTask.setUserRef(exportPermissions(taskToExport.getUserRefs())); + xmlTask.setUserRef(exportPermissions(taskToExport.getUserRefs())); + xmlTask.setUser(exportPermissions(taskToExport.getUsers())); + xmlTask.setAssignedUserPolicies(exportAssignedUserPolicy(taskToExport.getAssignedUserPolicy())); + xmlTask.setTriggers(exportTriggers(taskToExport.getTriggers())); + return xmlTask; + } + + private void exportDataFields() { + LinkedHashMap dataSet = this.caseToExport.getDataSet(); + if (dataSet == null || dataSet.isEmpty()) { + return; + } + dataSet.forEach((key, value) -> xmlCase.getDataField().add(exportDataField(key, value))); + } + + private DataField exportDataField(String fieldId, com.netgrif.application.engine.workflow.domain.DataField dataFieldToExport) { + DataField xmlDataField = objectFactory.createDataField(); + xmlDataField.setId(fieldId); + xmlDataField.setType(DataType.fromValue(caseToExport.getField(fieldId).getType().getName())); + xmlDataField.setValues(exportDataFieldValue(dataFieldToExport.getValue(), caseToExport.getField(fieldId).getType())); + if (this.caseToExport.getField(fieldId).getType().equals(FieldType.FILTER)) { + xmlDataField.setFilterMetadata(exportFilterMetadata(dataFieldToExport.getFilterMetadata())); + } + xmlDataField.setEncryption(dataFieldToExport.getEncryption()); + xmlDataField.setLastModified(exportLocalDateTime(dataFieldToExport.getLastModified())); + xmlDataField.setVersion(dataFieldToExport.getVersion()); + xmlDataField.setAllowedNets(exportCollectionOfStrings(dataFieldToExport.getAllowedNets())); + xmlDataField.setComponent(exportComponent(dataFieldToExport.getComponent())); + xmlDataField.getDataRefComponent().addAll(exportDataRefComponents(dataFieldToExport.getDataRefComponents())); + xmlDataField.setValidations(exportValidations(dataFieldToExport.getValidations())); + if (dataFieldToExport.getOptions() != null) { + xmlDataField.setOptions(exportOptions(dataFieldToExport.getOptions())); + } + if (dataFieldToExport.getChoices() != null) { + xmlDataField.setOptions(exportChoices(dataFieldToExport.getChoices())); + } + xmlDataField.setBehaviors(exportTaskBehaviors(dataFieldToExport.getBehavior())); + return xmlDataField; + } + + private FilterMetadata exportFilterMetadata(Map filterMetadata) { +// todo refactor whole metadata object + if (filterMetadata == null || filterMetadata.isEmpty()) { + return null; + } + FilterMetadata xmlMetadata = objectFactory.createFilterMetadata(); + xmlMetadata.setFilterType(FilterType.fromValue(filterMetadata.get("filterType").toString())); + xmlMetadata.setPredicateMetadata(parsePredicateTreeMetadata((List>) filterMetadata.get("predicateTreeMetadata"))); + xmlMetadata.getSearchCategories().getValue().addAll((List) filterMetadata.get("searchCategories")); + xmlMetadata.setDefaultSearchCategories((Boolean) filterMetadata.get("defaultSearchCategories")); + xmlMetadata.setInheritAllowedNets((Boolean) filterMetadata.get("inheritAllowedNets")); + return xmlMetadata; + } + + private PredicateTreeMetadata parsePredicateTreeMetadata(List> predicateTreeMetadata) { + if (predicateTreeMetadata == null || predicateTreeMetadata.isEmpty()) { + return null; + } + PredicateTreeMetadata xmlPredicateTreeMetadata = objectFactory.createPredicateTreeMetadata(); + predicateTreeMetadata.forEach(list -> { + if (list == null || list.isEmpty()) { + return; + } + PredicateMetadataArray metadataArray = objectFactory.createPredicateMetadataArray(); + list.forEach(data -> { + if (data == null) { + return; + } + CategoryGeneratorMetadata metadata = objectFactory.createCategoryGeneratorMetadata(); + Map retypedData = (Map) data; + metadata.setCategory(retypedData.get("category").toString()); + metadata.setValues(exportCollectionOfStrings((Collection) retypedData.get("category"))); + metadata.setConfiguration(exportMetadataConfiguration((Map) retypedData.get("configuration"))); + metadataArray.getData().add(metadata); + }); + xmlPredicateTreeMetadata.getPredicate().add(metadataArray); + }); + return xmlPredicateTreeMetadata; + } + + private CategoryMetadataConfiguration exportMetadataConfiguration(Map configuration) { + if (configuration == null || configuration.isEmpty()) { + return null; + } + CategoryMetadataConfiguration xmlMetadata = objectFactory.createCategoryMetadataConfiguration(); + configuration.forEach((key, value) -> { + ConfigurationValue xmlValue = objectFactory.createConfigurationValue(); + xmlValue.setValue(value); + xmlValue.setId(key); + xmlMetadata.getValue().add(xmlValue); + }); + return xmlMetadata; + } + + private TaskBehaviors exportTaskBehaviors(Map> taskBehavior) { + if (taskBehavior == null || taskBehavior.isEmpty()) { + return null; + } + TaskBehaviors xmlTaskBehaviors = new TaskBehaviors(); + taskBehavior.forEach((taskId, behaviors) -> { + Behaviors xmlBehavior = objectFactory.createBehaviors(); + xmlBehavior.setTaskId(taskId); + behaviors.forEach(behavior -> xmlBehavior.getBehavior().add(Behavior.fromValue(behavior.toString()))); + xmlTaskBehaviors.getTaskBehavior().add(xmlBehavior); + }); + return xmlTaskBehaviors; + } + + private Options exportOptions(Map options) { + if (options == null || options.isEmpty()) { + return null; + } + Options xmlOptions = objectFactory.createOptions(); + options.forEach((key, option) -> { + Option xmlOption = objectFactory.createOption(); + xmlOption.setKey(key); + xmlOption.setName(option.getKey()); + xmlOption.setValue(option.getDefaultValue()); + xmlOptions.getOption().add(xmlOption); + exportTranslations(option.getTranslations(), option.getKey()); + }); + return xmlOptions; + } + + private Options exportChoices(Set options) { + if (options == null || options.isEmpty()) { + return null; + } + Options xmlOptions = objectFactory.createOptions(); + options.forEach(option -> { + Option xmlOption = objectFactory.createOption(); + xmlOption.setName(option.getKey()); + xmlOption.setValue(option.getDefaultValue()); + xmlOptions.getOption().add(xmlOption); + exportTranslations(option.getTranslations(), option.getKey()); + }); + return xmlOptions; + } + + private void exportTranslations(Map translations, String name) { + translations.forEach((locale, translation) -> { + I18N localeTranslations = this.translations.get(locale); + if (localeTranslations == null) { + localeTranslations = objectFactory.createI18N(); + localeTranslations.setLocale(locale); + this.translations.put(locale, localeTranslations); + } + I18NStringType i18nStringType = objectFactory.createI18NStringType(); + i18nStringType.setValue(translation); + i18nStringType.setName(name); + localeTranslations.getI18NString().add(i18nStringType); + }); + } + + private Validations exportValidations(List validations) { + if (validations == null || validations.isEmpty()) { + return null; + } + Validations xmlValidations = objectFactory.createValidations(); + validations.forEach(validation -> { +// todo resolve other Validation object properties after NAE-1892 is merged into 7.0.0 + Validation xmlValidation = objectFactory.createValidation(); + xmlValidation.setMessage(exportI18NString(validation.getValidationMessage())); + Valid rule = objectFactory.createValid(); + rule.setValue(validation.getValidationRule()); + xmlValidation.setExpression(rule); + xmlValidations.getValidation().add(xmlValidation); + }); + return xmlValidations; + } + + private List exportDataRefComponents(Map dataRefComponents) { + if (dataRefComponents == null || dataRefComponents.isEmpty()) { + return Collections.emptyList(); + } + List xmlDataRefComponents = new ArrayList<>(); + dataRefComponents.forEach((taskId, component) -> { + DataRefComponent xmlDataRefComponent = objectFactory.createDataRefComponent(); + Component xmlComponent = exportComponent(component); + xmlDataRefComponent.setTaskId(taskId); + xmlDataRefComponent.setComponent(xmlComponent); + xmlDataRefComponents.add(xmlDataRefComponent); + }); + return xmlDataRefComponents; + } + + private Component exportComponent(com.netgrif.application.engine.petrinet.domain.Component component) { + if (component == null) { + return null; + } + Component xmlComponent = objectFactory.createComponent(); + xmlComponent.setName(component.getName()); + xmlComponent.setProperties(exportProperties(component.getProperties(), component.getOptionIcons())); + return xmlComponent; + } + + private Properties exportProperties(Map properties, List optionIcons) { + if ((properties == null || properties.isEmpty()) && (optionIcons == null || optionIcons.isEmpty())) { + return null; + } + Properties xmlProperties = objectFactory.createProperties(); + if (properties != null) { + properties.forEach((key, value) -> { + Property xmlProperty = objectFactory.createProperty(); + xmlProperty.setKey(key); + xmlProperty.setValue(value); + xmlProperties.getProperty().add(xmlProperty); + }); + } + if (optionIcons != null) { + optionIcons.forEach(icon -> { + Icons xmlIcons = objectFactory.createIcons(); + Icon xmlIcon = objectFactory.createIcon(); + xmlIcon.setKey(icon.getKey()); + xmlIcon.setValue(icon.getValue()); + xmlIcon.setType(IconType.fromValue(icon.getType())); + xmlProperties.setOptionIcons(xmlIcons); + }); + } + return xmlProperties; + } + + private StringCollection exportDataFieldValue(Object value, FieldType type) { + if (value == null) { + return null; + } + StringCollection values = objectFactory.createStringCollection(); + switch (type) { + case DATE: + case DATETIME: + LocalDateTime localDateTime = value instanceof Date castValue ? convertDateToLocalDateTime(castValue) : (value instanceof LocalDate castLocalDateValue ? castLocalDateValue.atTime(LocalTime.NOON) : (LocalDateTime) value); + values.getValue().add(exportLocalDateTime(localDateTime)); + break; + case CASE_REF: + case TASK_REF: + case MULTICHOICE: + case STRING_COLLECTION: + case MULTICHOICE_MAP: + ((Collection) value).forEach(it -> values.getValue().add(it.toString())); + break; + case USER: + case USERLIST: + Set userFieldValues = new HashSet<>(); + if (value instanceof UserFieldValue castValue) { + userFieldValues.add(castValue); + } else { + userFieldValues = ((UserListFieldValue) value).getUserValues(); + } + userFieldValues.forEach(userFieldValue -> values.getValue().add(userFieldValue.getId())); + break; + case FILE: + case FILELIST: + Set fileFieldValues = new HashSet<>(); + if (value instanceof FileFieldValue castValue) { + fileFieldValues.add(castValue); + } else { + fileFieldValues = ((FileListFieldValue) value).getNamesPaths(); + } + fileFieldValues.forEach(fieldValue -> values.getValue().add(fieldValue.getName())); + break; + case I18N: + exportI18NString((I18nString) value); + values.getValue().add(value.toString()); + values.setId(((I18nString) value).getKey()); + break; + default: + values.getValue().add(value.toString()); + break; + } + return values; + } + + private LocalDateTime convertDateToLocalDateTime(Date value) { + return value.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + } + + private BigInteger exportInteger(Integer priority) { + return priority == null ? null : BigInteger.valueOf(priority); + } + + private TaskTrigger exportTriggers(List triggers) { + if (triggers == null || triggers.isEmpty()) { + return null; + } + TaskTrigger taskTrigger = objectFactory.createTaskTrigger(); + triggers.forEach(trigger -> { + TriggerWithId xmlTrigger = objectFactory.createTriggerWithId(); + xmlTrigger.setId(trigger.get_id().toString()); + TriggerType triggerTypeString; + if (trigger instanceof AutoTrigger) { + triggerTypeString = TriggerType.AUTO; + } else if (trigger instanceof TimeTrigger) { + triggerTypeString = TriggerType.TIME; + } else { + triggerTypeString = TriggerType.USER; + } + xmlTrigger.setType(triggerTypeString); + taskTrigger.getTrigger().add(xmlTrigger); + }); + return taskTrigger; + } + + private AssignedUserPolicies exportAssignedUserPolicy(Map assignedUserPolicy) { + AssignedUserPolicies assignedUserPolicies = objectFactory.createAssignedUserPolicies(); + assignedUserPolicies.getAssignedUserPolicy().addAll(exportBooleanMap(assignedUserPolicy)); + return assignedUserPolicies; + } + + private Tags exportTags(Map tags) { + if (tags == null || tags.isEmpty()) { + return null; + } + Tags xmlTags = objectFactory.createTags(); + tags.forEach((key, value) -> { + Tag tag = new Tag(); + tag.setKey(key); + tag.setValue(value); + xmlTags.getTag().add(tag); + }); + return xmlTags; + } + + private I18NStringType exportI18NString(I18nString i18nString) { + if (i18nString == null) { + return null; + } + I18NStringType i18NStringType = objectFactory.createI18NStringType(); +// todo i18n name is not kept after petri net import, i18n import needs to be refactored as a whole + i18NStringType.setName(i18nString.getKey()); + i18NStringType.setValue(i18nString.getDefaultValue()); + exportTranslations(i18nString.getTranslations(), i18nString.getKey()); + return i18NStringType; + } + + private String exportLocalDateTime(LocalDateTime toExport) { +// todo LocalDate and DateTime do not store information about Zone/Offset, Date and DateTime fields needs to be refactored to store values as either Zoned or Offset dateTime + return toExport == null ? null : toExport.atZone(ZoneId.systemDefault()).truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + + private StringCollection exportCollectionOfStrings(Collection collection) { + if (collection == null || collection.isEmpty()) { + return null; + } + StringCollection stringCollection = objectFactory.createStringCollection(); + stringCollection.getValue().addAll(collection); + return stringCollection; + } + + private MapXsdType exportMapXsdType(Map map) { + if (map == null || map.isEmpty()) { + return null; + } + MapXsdType xsdType = objectFactory.createMapXsdType(); + map.forEach((key, value) -> { + IntegerMapEntry mapEntry = objectFactory.createIntegerMapEntry(); + mapEntry.setKey(key); + mapEntry.setValue(exportInteger(value)); + xsdType.getEntry().add(mapEntry); + }); + return xsdType; + } + + private PermissionMap exportPermissions(Map> permissions) { + PermissionMap permissionMap = objectFactory.createPermissionMap(); + permissions.forEach((key, value) -> { + Permissions permission = objectFactory.createPermissions(); + permission.setId(key); + permission.getPermission().addAll(exportBooleanMap(value)); + permissionMap.getEntry().add(permission); + }); + return permissionMap; + } + + private List exportBooleanMap(Map toExport) { + List booleanMapEntryList = new ArrayList<>(); + toExport.forEach((id, permissionValue) -> { + BooleanMapEntry mapEntry = objectFactory.createBooleanMapEntry(); + mapEntry.setKey(id); + mapEntry.setValue(permissionValue); + booleanMapEntryList.add(mapEntry); + }); + return booleanMapEntryList; + } +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/service/CaseImportExportService.java b/src/main/java/com/netgrif/application/engine/workflow/service/CaseImportExportService.java new file mode 100644 index 00000000000..1e54f1249b3 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/service/CaseImportExportService.java @@ -0,0 +1,168 @@ +package com.netgrif.application.engine.workflow.service; + +import com.netgrif.application.engine.archive.interfaces.IArchiveService; +import com.netgrif.application.engine.configuration.properties.CaseExportProperties; +import com.netgrif.application.engine.files.IStorageResolverService; +import com.netgrif.application.engine.files.interfaces.IStorageService; +import com.netgrif.application.engine.files.throwable.StorageException; +import com.netgrif.application.engine.petrinet.domain.dataset.FileFieldValue; +import com.netgrif.application.engine.petrinet.domain.dataset.FileListField; +import com.netgrif.application.engine.petrinet.domain.dataset.FileListFieldValue; +import com.netgrif.application.engine.petrinet.domain.dataset.StorageField; +import com.netgrif.application.engine.workflow.domain.*; +import com.netgrif.application.engine.workflow.exceptions.ImportXmlFileMissingException; +import com.netgrif.application.engine.workflow.service.interfaces.ICaseImportExportService; +import com.netgrif.application.engine.workflow.service.interfaces.ITaskService; +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.filefilter.DirectoryFileFilter; +import org.apache.commons.io.filefilter.FileFileFilter; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.stereotype.Service; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Slf4j +@RequiredArgsConstructor +public class CaseImportExportService implements ICaseImportExportService { + + private final ObjectFactory caseImporterObjectFactory; + private final ObjectFactory caseExporterObjectFactory; + private final IArchiveService archiveService; + private final IWorkflowService workflowService; + private final IStorageResolverService storageResolverService; + private final CaseExportProperties properties; + private final ITaskService taskService; + + protected CaseImporter getCaseImporter() { + return caseImporterObjectFactory.getObject(); + } + + protected CaseExporter getCaseExporter() { + return caseExporterObjectFactory.getObject(); + } + + @Override + public void findAndExportCases(Set caseIdsToExport, OutputStream exportStream) throws IOException { + this.exportCases(workflowService.findAllById(new ArrayList<>(caseIdsToExport)), exportStream); + } + + @Override + public void findAndExportCasesWithFiles(Set caseIdsToExport, OutputStream archiveStream) throws IOException { + this.exportCasesWithFiles(workflowService.findAllById(new ArrayList<>(caseIdsToExport)), archiveStream); + } + + @Override + public void exportCasesWithFiles(List casesToExport, OutputStream archiveStream) throws IOException { + File exportFile = new File(Files.createTempDirectory("case_export").toFile(), properties.getFileName()); + this.exportCases(casesToExport, new FileOutputStream(exportFile)); + CaseExportFiles caseFiles = this.getFileNamesOfCases(casesToExport); + archiveService.pack(archiveStream, caseFiles, exportFile.getPath()); + FileUtils.deleteDirectory(exportFile.getParentFile()); + } + + @Override + public void exportCases(List casesToExport, OutputStream exportStream) throws IOException { + this.exportCasesToFile(casesToExport, exportStream); + exportStream.close(); + } + + @Override + public CaseExportFiles getFileNamesOfCases(List casesToExport) { + CaseExportFiles filesToExport = new CaseExportFiles(); + for (Case exportCase : casesToExport) { + exportCase.getPetriNet().getDataSet().values().stream() + .filter(field -> field instanceof StorageField) + .forEach(field -> { + DataField dataField = exportCase.getDataField(field.getStringId()); + if (dataField == null || dataField.getValue() == null) { + return; + } + HashSet namesPaths = new HashSet<>(); + if (field instanceof FileListField) { + ((FileListFieldValue) dataField.getValue()).getNamesPaths().forEach(value -> namesPaths.add(value.getName())); + } else { + namesPaths.add(((FileFieldValue) dataField.getValue()).getName()); + } + filesToExport.addFieldFilenames(exportCase.getStringId(), new StorageFieldWithFileNames((StorageField) field, namesPaths)); + }); + } + return filesToExport; + } + + @Override + public List importCases(InputStream inputStream) { + CaseImporter importer = getCaseImporter(); + List importedCases = importer.importCases(inputStream); + if (importedCases.isEmpty()) { + return importedCases; + } + return saveImportedObjects(importedCases, importer.getImportedTasksMap()).values().stream().toList(); + } + + @Override + public List importCasesWithFiles(InputStream importZipStream) throws IOException, StorageException, ImportXmlFileMissingException { + String directoryPath = archiveService.unpack(importZipStream, Files.createTempDirectory(UUID.randomUUID().toString()).toString()); + importZipStream.close(); + File caseExportXmlFile = FileUtils.getFile(new File(directoryPath.concat(File.separator).concat(properties.getFileName()))); + if (!caseExportXmlFile.exists() || caseExportXmlFile.isDirectory()) { + throw new ImportXmlFileMissingException("Xml import file with name [" + properties.getFileName() + "] not found in archive"); + } + FileInputStream fis = new FileInputStream(caseExportXmlFile); + CaseImporter importer = getCaseImporter(); + List importedCases = importer.importCases(fis); + fis.close(); + if (importedCases.isEmpty()) { + return importedCases; + } + Map importedCasesMap = saveImportedObjects(importedCases, importer.getImportedTasksMap()); + List caseFilesDirectories = Arrays.stream(Objects.requireNonNull(new File(directoryPath).list(DirectoryFileFilter.DIRECTORY))) + .map(caseDirectory -> directoryPath.concat(File.separator).concat(caseDirectory)) + .toList(); + Map idsToCaseMapping = new HashMap<>(); + importer.getImportedIdsMapping().forEach((oldCaseId, newCaseId) -> { + idsToCaseMapping.put(oldCaseId, importedCasesMap.get(newCaseId)); + }); + saveFiles(caseFilesDirectories, idsToCaseMapping); + FileUtils.forceDelete(caseExportXmlFile.getParentFile()); + return importedCases; + } + + private void saveFiles(List casesDirectories, Map importedIdsMapping) throws IOException, StorageException { + for (String caseDirectory : casesDirectories) { + Case importedCase = importedIdsMapping.get(Paths.get(caseDirectory).getFileName().toString()); + List fieldsOfCaseDirectories = Arrays.stream(Objects.requireNonNull(new File(caseDirectory).list(DirectoryFileFilter.DIRECTORY))).toList(); + for (String fieldDirectory : fieldsOfCaseDirectories) { + String fieldDirectoryPath = caseDirectory.concat(File.separator).concat(fieldDirectory); + StorageField field = (StorageField) importedCase.getField(Paths.get(fieldDirectoryPath).getFileName().toString()); + IStorageService storageService = storageResolverService.resolve(field.getStorageType()); + for (String fileName : Objects.requireNonNull(new File(fieldDirectoryPath).list(FileFileFilter.FILE))) { + String filePath = fieldDirectoryPath.concat(File.separator).concat(fileName); + String path = storageService.getPath(importedCase.getStringId(), field.getStringId(), fileName); + FileInputStream fis = new FileInputStream(filePath); + storageService.save(field, path, fis); + fis.close(); + } + } + } + } + + private Map saveImportedObjects(List importedCases, Map> importedTasks) { + return importedCases.stream().map(importedCase -> { + taskService.save(importedTasks.get(importedCase.getStringId())); + return workflowService.save(importedCase); + }).collect(Collectors.toMap(Case::getStringId, c -> c)); + } + + private void exportCasesToFile(Collection casesToExport, OutputStream exportFile) { + CaseExporter caseExporter = getCaseExporter(); + caseExporter.exportCases(casesToExport, exportFile); + } +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/service/CaseImporter.java b/src/main/java/com/netgrif/application/engine/workflow/service/CaseImporter.java new file mode 100644 index 00000000000..d005314fc2e --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/service/CaseImporter.java @@ -0,0 +1,499 @@ +package com.netgrif.application.engine.workflow.service; + +import com.netgrif.application.engine.auth.domain.IUser; +import com.netgrif.application.engine.auth.service.interfaces.IUserService; +import com.netgrif.application.engine.files.StorageResolverService; +import com.netgrif.application.engine.files.interfaces.IStorageService; +import com.netgrif.application.engine.importer.model.*; +import com.netgrif.application.engine.importer.service.ComponentFactory; +import com.netgrif.application.engine.petrinet.domain.*; +import com.netgrif.application.engine.petrinet.domain.Component; +import com.netgrif.application.engine.petrinet.domain.Transaction; +import com.netgrif.application.engine.petrinet.domain.Transition; +import com.netgrif.application.engine.petrinet.domain.dataset.*; +import com.netgrif.application.engine.petrinet.domain.dataset.logic.FieldBehavior; +import com.netgrif.application.engine.petrinet.domain.dataset.logic.validation.Validation; +import com.netgrif.application.engine.petrinet.domain.roles.ProcessRole; +import com.netgrif.application.engine.petrinet.domain.version.Version; +import com.netgrif.application.engine.petrinet.service.PetriNetService; +import com.netgrif.application.engine.petrinet.service.interfaces.IProcessRoleService; +import com.netgrif.application.engine.utils.ImporterUtils; +import com.netgrif.application.engine.workflow.domain.Case; +import com.netgrif.application.engine.workflow.domain.DataField; +import com.netgrif.application.engine.workflow.domain.ProcessResourceId; +import com.netgrif.application.engine.workflow.domain.Task; +import com.netgrif.application.engine.workflow.domain.triggers.TimeTrigger; +import com.netgrif.application.engine.workflow.domain.triggers.Trigger; +import com.netgrif.application.engine.workflow.service.interfaces.ITaskService; +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Unmarshaller; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +public class CaseImporter { + + @Autowired + private IWorkflowService workflowService; + + @Autowired + private PetriNetService petriNetService; + + @Autowired + private IUserService userService; + + @Autowired + private ComponentFactory componentFactory; + + @Autowired + private ITaskService taskService; + + @Autowired + private IProcessRoleService processRoleService; + + @Autowired + private StorageResolverService storageResolverService; + + private Cases xmlCases; + private Case importedCase; + private com.netgrif.application.engine.importer.model.Case xmlCase; + @Getter + private final Map> importedTasksMap = new HashMap<>(); + @Getter + private final Map importedIdsMapping = new HashMap<>(); + + @Transactional + public List importCases(InputStream xml) { + List importedCases = new ArrayList<>(); + try { + unmarshallXml(xml); + } catch (JAXBException e) { + log.error("Error unmarshalling input xml file: ", e); + return importedCases; + } + for (com.netgrif.application.engine.importer.model.Case xmlCase : xmlCases.getCase()) { + this.xmlCase = xmlCase; + this.importedCase = null; + importCase(); + importedCases.add(this.importedCase); + } + return importedCases; + } + + @Transactional + protected void importCase() { + Version version = new Version(); + PetriNet model = petriNetService.getPetriNet(xmlCase.getProcessIdentifier(), version); + if (model == null) { +// todo throw error? + log.error("Petri net with identifier [{}] not found, skipping case import", xmlCase.getProcessIdentifier()); + return; + } + ProcessResourceId importedCaseId = new ProcessResourceId(model.getStringId(), xmlCase.getId().split("-")[1].trim()); + try { + workflowService.findOne(importedCaseId.toString()); + log.warn("Case with id [{}] already exists, new id will be generated for imported case", importedCaseId); + this.importedCase = new Case(model); + } catch (IllegalArgumentException e) { + this.importedCase = new Case(model, importedCaseId); + } + importedIdsMapping.put(xmlCase.getId(), importedCase.get_id().toString()); + importCaseMetadata(); + importTasks(); + importDataSet(); + } + + @Transactional + protected void importTasks() { + List importedTasks = new ArrayList<>(); + if (xmlCase.getTask() == null) { + return; + } + this.xmlCase.getTask().forEach(task -> { + Transition transition = this.importedCase.getPetriNet().getTransition(task.getTransitionId()); + final Task importedTask = Task.with() + .title(transition.getTitle()) + ._id(new ProcessResourceId(this.importedCase.getPetriNetId(), task.getId().split("-")[1].trim())) + .caseId(this.importedCase.getStringId()) + .processId(this.importedCase.getProcessIdentifier()) + .transitionId(transition.getImportId()) + .layout(transition.getLayout()) + .tags(transition.getTags()) + .userId(task.getUserId()) + .startDate(parseDateTimeFromXml(task.getStartDate())) + .finishDate(parseDateTimeFromXml(task.getFinishDate())) + .finishedBy(task.getFinishedBy()) + .caseColor(this.importedCase.getColor()) + .caseTitle(this.importedCase.getTitle()) + .priority(transition.getPriority()) + .icon(transition.getIcon() == null ? this.importedCase.getIcon() : transition.getIcon()) + .immediateDataFields(new LinkedHashSet<>(transition.getImmediateData())) + .assignPolicy(transition.getAssignPolicy()) + .dataFocusPolicy(transition.getDataFocusPolicy()) + .finishPolicy(transition.getFinishPolicy()) + .build(); + transition.getEvents().forEach((type, event) -> importedTask.addEventTitle(type, event.getTitle())); + importedTask.addAssignedUserPolicy(transition.getAssignedUserPolicy()); + for (Trigger trigger : transition.getTriggers()) { + Trigger taskTrigger = trigger.clone(); + importedTask.addTrigger(taskTrigger); + + if (taskTrigger instanceof TimeTrigger timeTrigger) { + taskService.scheduleTaskExecution(importedTask, timeTrigger.getStartDate(), this.importedCase); + } + } + ProcessRole defaultRole = processRoleService.defaultRole(); + ProcessRole anonymousRole = processRoleService.anonymousRole(); + for (Map.Entry> entry : transition.getRoles().entrySet()) { + if (this.importedCase.getEnabledRoles().contains(entry.getKey()) + || defaultRole.getStringId().equals(entry.getKey()) + || anonymousRole.getStringId().equals(entry.getKey())) { + importedTask.addRole(entry.getKey(), entry.getValue()); + } + } + transition.getNegativeViewRoles().forEach(importedTask::addNegativeViewRole); + + for (Map.Entry> entry : transition.getUserRefs().entrySet()) { + importedTask.addUserRef(entry.getKey(), entry.getValue()); + } + importedTask.resolveViewRoles(); + importedTask.resolveViewUserRefs(); + + Transaction transaction = this.importedCase.getPetriNet().getTransactionByTransition(transition); + if (transaction != null) { + importedTask.setTransactionId(transaction.getStringId()); + } + + importedTasks.add(importedTask); + importedCase.addTask(importedTask); + importedIdsMapping.put(task.getId(), importedTask.get_id().toString()); + }); + importedTasksMap.put(this.importedCase.getStringId(), importedTasks); + } + + @Transactional + protected void importDataSet() { + xmlCase.getDataField().forEach(field -> { + DataField dataField = new DataField(); + dataField.setEncryption(field.getEncryption()); + dataField.setLastModified(parseDateTimeFromXml(field.getLastModified())); + dataField.setVersion(field.getVersion()); + if (field.getType() == DataType.FILTER) { + dataField.setFilterMetadata(parseFilterMetadata(field.getFilterMetadata())); + } + dataField.setValue(parseXmlValue(field)); + dataField.setComponent(parseXmlComponent(field.getComponent())); + field.getDataRefComponent().stream() + .filter(dataRefComponent -> dataRefComponent.getComponent() != null) + .forEach(dataRefComponent -> dataField.getDataRefComponents().put(dataRefComponent.getTaskId(), parseXmlComponent(dataRefComponent.getComponent()))); + dataField.setValidations(parseXmlValidations(field.getValidations())); + if(field.getType() == DataType.ENUMERATION || field.getType() == DataType.MULTICHOICE) { + dataField.setChoices(new HashSet<>(parseXmlOptions(field.getOptions()).values())); + } + if(field.getType() == DataType.ENUMERATION_MAP || field.getType() == DataType.MULTICHOICE_MAP) { + dataField.setOptions(parseXmlOptions(field.getOptions())); + } + dataField.setBehavior(parseXmlBehaviors(field.getBehaviors())); + if(field.getType() == DataType.CASE_REF) { + dataField.setAllowedNets(parseStringCollection(field.getAllowedNets())); + } + importedCase.getDataSet().put(field.getId(), dataField); + }); + } + + private Map parseFilterMetadata(FilterMetadata filterMetadata) { + Map filterMetadataMap = new HashMap<>(); + if (filterMetadata == null) { + return filterMetadataMap; + } + filterMetadataMap.put("filterType", filterMetadata.getFilterType().toString()); + filterMetadataMap.put("predicateMetadata", parsePredicateMetadata(filterMetadata.getPredicateMetadata())); + filterMetadataMap.put("searchCategories", filterMetadata.getSearchCategories()); + filterMetadataMap.put("defaultSearchCategories", filterMetadata.isDefaultSearchCategories()); + filterMetadataMap.put("inheritAllowedNets", filterMetadata.isInheritAllowedNets()); + return filterMetadataMap; + } + + private Object parsePredicateMetadata(PredicateTreeMetadata predicateMetadata) { + if (predicateMetadata == null || predicateMetadata.getPredicate() == null) { + return null; + } + List> values = new ArrayList<>(); + predicateMetadata.getPredicate().forEach(predicate -> { + if (predicate == null) { + return; + } + List value = new ArrayList<>(); + predicate.getData().forEach(data -> { + if (data == null) { + return; + } + Map dataMap = new HashMap<>(); + dataMap.put("category", data.getCategory()); + dataMap.put("values", parseStringCollection(data.getValues())); + dataMap.put("configuration", parseConfiguration(data.getConfiguration())); + value.add(dataMap); + }); + values.add(value); + }); + return values; + } + + private Object parseConfiguration(CategoryMetadataConfiguration configuration) { + if (configuration == null) { + return null; + } + Map configurationMap = new HashMap<>(); + configuration.getValue().forEach(data -> { + if (data == null) { + return; + } + configurationMap.put(data.getId(), data.getValue()); + }); + return configurationMap; + } + + private Object parseXmlValue(com.netgrif.application.engine.importer.model.DataField field) { + StringCollection value = field.getValues(); + DataType dataType = field.getType(); + if (value == null || value.getValue() == null || value.getValue().isEmpty()) { + return null; + } + Object parsedValue; + switch (dataType) { + case DATE: + case DATE_TIME: + parsedValue = parseDateTimeFromXml(value.getValue().getFirst()); + if (dataType == com.netgrif.application.engine.importer.model.DataType.DATE) { + parsedValue = ((LocalDateTime) parsedValue).toLocalDate(); + } + break; + case STRING_COLLECTION: + parsedValue = parseStringCollection(value); + break; + case CASE_REF: + case TASK_REF: + parsedValue = parseStringCollection(value).stream() + .filter(this.importedIdsMapping::containsKey) + .map(this.importedIdsMapping::get).toList(); + break; + case NUMBER: + parsedValue = Double.parseDouble(value.getValue().getFirst()); + break; + case BOOLEAN: + parsedValue = Boolean.parseBoolean(value.getValue().getFirst()); + break; + case MULTICHOICE: + case MULTICHOICE_MAP: + parsedValue = parseStringCollection(value, new LinkedHashSet<>()); + if (dataType == com.netgrif.application.engine.importer.model.DataType.MULTICHOICE) { + parsedValue = ((Set) parsedValue).stream().map(it -> { + I18nString i18nString = new I18nString(it); + i18nString.setTranslations(parseTranslations(i18nString.getKey())); + return i18nString; + }).collect(Collectors.toCollection(LinkedHashSet::new)); + } + break; + case USER: + parsedValue = parseUserFieldValue(value.getValue().getFirst()); + break; + case USER_LIST: + List userFieldValues = value.getValue().stream() + .map(this::parseUserFieldValue) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + if (userFieldValues.isEmpty()) { + parsedValue = null; + } else { + parsedValue = new UserListFieldValue(userFieldValues); + } + break; + case FILE: + parsedValue = createFileFieldValue(field, value.getValue().getFirst()); + break; + case FILE_LIST: + FileListFieldValue fileListValue = new FileListFieldValue(); + value.getValue().forEach(fileName -> fileListValue.getNamesPaths().add(createFileFieldValue(field, fileName))); + parsedValue = fileListValue; + break; + case ENUMERATION: + case I_18_N: + parsedValue = new I18nString(value.getValue().getFirst()); + ((I18nString) parsedValue).setTranslations(parseTranslations(value.getId())); + break; + case BUTTON: + parsedValue = Integer.parseInt(value.getValue().getFirst()); + break; + default: + parsedValue = value.getValue().getFirst(); + break; + } + return parsedValue; + } + + private Map parseTranslations(String name) { + Map translations = new HashMap<>(); + this.xmlCase.getI18N().forEach(i18n -> i18n.getI18NString().stream() + .filter(i18nString -> i18nString.getName().equals(name)) + .forEach(translation -> translations.put(translation.getName(), translation.getName()))); + return translations; + } + + private FileFieldValue createFileFieldValue(com.netgrif.application.engine.importer.model.DataField field, String fileName) { + IStorageService storageService = storageResolverService.resolve(((StorageField) importedCase.getField(field.getId())).getStorageType()); + String path = storageService.getPath(this.importedCase.getStringId(), field.getId(), fileName); + return new FileFieldValue(fileName, path); + } + + private UserFieldValue parseUserFieldValue(String xmlValue) { + IUser user; + try { + user = userService.resolveById(xmlValue, true); + return new UserFieldValue(user); + } catch (IllegalArgumentException e) { + log.warn("User with id [{}] not found, setting empty value", xmlValue); + return null; + } + } + + private Map> parseXmlBehaviors(TaskBehaviors behaviors) { + Map> behaviorMap = new HashMap<>(); + if (behaviors == null || behaviors.getTaskBehavior() == null || behaviors.getTaskBehavior().isEmpty()) { + return behaviorMap; + } + behaviors.getTaskBehavior().forEach(taskBehavior -> { + Set behaviorSet = new HashSet<>(); + taskBehavior.getBehavior().forEach(behavior -> behaviorSet.add(FieldBehavior.fromString(behavior))); + behaviorMap.put(taskBehavior.getTaskId(), behaviorSet); + }); + return behaviorMap; + } + + private Map parseXmlOptions(Options options) { + Map optionsMap = new HashMap<>(); + if (options == null || options.getOption() == null) { + return optionsMap; + } + options.getOption().forEach(option -> optionsMap.put(option.getKey(), parseXmlI18nString(option))); + return optionsMap; + } + + private List parseXmlValidations(Validations validations) { + List parsedValidations = new ArrayList<>(); + if (validations == null) { + return parsedValidations; + } + validations.getValidation().forEach(validation -> { +// todo problem with i18n translations, petriNet does not store information about translations on their own, they get resolved during net import and are stored in i18n objects +// todo export i18ns with their translations? refactor petriNet? + parsedValidations.add(ImporterUtils.makeValidation(validation.getExpression().getValue(), parseXmlI18nString(validation.getMessage()), validation.getExpression().isDynamic())); + }); + return parsedValidations; + } + + private I18nString parseXmlI18nString(I18NStringType xmlI18nString) { + if (xmlI18nString == null) { + return null; + } + I18nString parsedI18n = new I18nString(); + parsedI18n.setKey(xmlI18nString.getName()); + parsedI18n.setDefaultValue(xmlI18nString.getValue()); + parsedI18n.setTranslations(parseTranslations(xmlI18nString.getName())); + return parsedI18n; + } + + private Component parseXmlComponent(com.netgrif.application.engine.importer.model.Component component) { + return component == null ? null : componentFactory.buildComponent(component); + } + + @Transactional + protected void importCaseMetadata() { +// todo id and visualId cannot be set, both are generated in constructor + IUser user; + try { + user = userService.findById(xmlCase.getAuthor(), true); + } catch (IllegalArgumentException e) { + log.warn("Author of case to be imported not found, setting technical user as author"); + user = userService.getSystem(); + } + importedCase.setAuthor(user.transformToAuthor()); + if (xmlCase.getTags() != null) { + importedCase.setTags(ImporterUtils.buildTagsMap(xmlCase.getTags().getTag())); + } +// todo date validate + importedCase.setUriNodeId(xmlCase.getUriNodeId()); + importedCase.setCreationDate(parseDateTimeFromXml(xmlCase.getCreationDate())); + importedCase.setLastModified(parseDateTimeFromXml(xmlCase.getLastModified())); + importedCase.setViewUserRefs(parseStringCollection(xmlCase.getViewUserRefs())); + importedCase.setViewUsers(parseStringCollection(xmlCase.getViewUsers())); + importedCase.setNegativeViewUsers(parseStringCollection(xmlCase.getNegativeViewUsers())); + importedCase.setConsumedTokens(parseMapXsdType(xmlCase.getConsumedTokens())); + importedCase.setUsers(parsePermissionMap(xmlCase.getUsers())); + importedCase.setTitle(xmlCase.getTitle()); + importedCase.getPetriNet().initializeArcs(importedCase.getDataSet()); + updateCaseState(); + } + + private void updateCaseState() { + Map activePlaces = parseMapXsdType(xmlCase.getActivePlaces()); + this.importedCase.getPetriNet().getPlaces().forEach((placeId, place) -> place.setTokens(activePlaces.getOrDefault(placeId, 0))); + this.importedCase.setActivePlaces(activePlaces); + workflowService.updateMarking(this.importedCase); + } + + private List parseStringCollection(StringCollection xmlCollection) { + return parseStringCollection(xmlCollection, new ArrayList<>()); + } + + private T parseStringCollection(StringCollection xmlCollection, T collection) { + if (xmlCollection != null) { + collection.addAll(xmlCollection.getValue()); + } + return collection; + } + + private Map> parsePermissionMap(PermissionMap permissions) { + Map> permissionMap = new HashMap<>(); + if (permissions == null) { + return permissionMap; + } + permissions.getEntry().forEach(entry -> { + Map permission = new HashMap<>(); + entry.getPermission().forEach(xmlPermission -> permission.put(xmlPermission.getKey(), xmlPermission.isValue())); + permissionMap.put(entry.getId(), permission); + }); + return permissionMap; + } + + private Map parseMapXsdType(MapXsdType consumedTokens) { + Map map = new HashMap<>(); + if (consumedTokens == null) { + return map; + } + consumedTokens.getEntry().forEach(entry -> map.put(entry.getKey(), entry.getValue().intValue())); + return map; + } + + @Transactional + protected void unmarshallXml(InputStream xml) throws JAXBException { + JAXBContext jaxbContext = JAXBContext.newInstance(Cases.class); + + Unmarshaller jaxbUnmarshaller = jaxbContext.createUnmarshaller(); + xmlCases = (Cases) jaxbUnmarshaller.unmarshal(xml); + } + + private LocalDateTime parseDateTimeFromXml(String xmlDateTimeString) { + return xmlDateTimeString == null ? null : LocalDateTime.parse(xmlDateTimeString, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/service/TaskService.java b/src/main/java/com/netgrif/application/engine/workflow/service/TaskService.java index 8a125be5a37..34e78475e01 100644 --- a/src/main/java/com/netgrif/application/engine/workflow/service/TaskService.java +++ b/src/main/java/com/netgrif/application/engine/workflow/service/TaskService.java @@ -612,7 +612,7 @@ void validateData(Transition transition, Case useCase) { } } - protected void scheduleTaskExecution(Task task, LocalDateTime time, Case useCase) { + public void scheduleTaskExecution(Task task, LocalDateTime time, Case useCase) { log.info("[" + useCase.getStringId() + "]: Task " + task.getTitle() + " scheduled to run at " + time.toString()); scheduler.schedule(() -> { try { diff --git a/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/ICaseImportExportService.java b/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/ICaseImportExportService.java new file mode 100644 index 00000000000..fe578b0de97 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/ICaseImportExportService.java @@ -0,0 +1,29 @@ +package com.netgrif.application.engine.workflow.service.interfaces; + +import com.netgrif.application.engine.files.throwable.StorageException; +import com.netgrif.application.engine.workflow.domain.Case; +import com.netgrif.application.engine.workflow.domain.CaseExportFiles; +import com.netgrif.application.engine.workflow.exceptions.ImportXmlFileMissingException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Set; + +public interface ICaseImportExportService { + + void findAndExportCases(Set caseIdsToExport, OutputStream exportStream) throws IOException; + + void findAndExportCasesWithFiles(Set caseIdsToExport, OutputStream archiveStream) throws IOException; + + void exportCasesWithFiles(List caseIdsToExport, OutputStream archiveStream) throws IOException; + + void exportCases(List casesToExport, OutputStream exportStream) throws IOException; + + CaseExportFiles getFileNamesOfCases(List casesToExport); + + List importCases(InputStream importStream); + + List importCasesWithFiles(InputStream importZipStream) throws IOException, StorageException, ImportXmlFileMissingException; +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/ITaskService.java b/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/ITaskService.java index 84370a74fca..3df1d4435ed 100644 --- a/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/ITaskService.java +++ b/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/ITaskService.java @@ -17,6 +17,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.*; public interface ITaskService { @@ -126,4 +127,6 @@ public interface ITaskService { List save(List tasks); SetDataEventOutcome getMainOutcome(Map outcomes, String taskId); + + void scheduleTaskExecution(Task task, LocalDateTime time, Case useCase); } \ No newline at end of file diff --git a/src/main/java/com/netgrif/application/engine/workflow/web/CaseExportController.java b/src/main/java/com/netgrif/application/engine/workflow/web/CaseExportController.java new file mode 100644 index 00000000000..a0c62be2098 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/web/CaseExportController.java @@ -0,0 +1,57 @@ +package com.netgrif.application.engine.workflow.web; + +import com.netgrif.application.engine.configuration.properties.CaseExportProperties; +import com.netgrif.application.engine.workflow.service.interfaces.ICaseImportExportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Set; + +@Slf4j +@RestController() +@RequestMapping("/api/case/export") +@AllArgsConstructor +public class CaseExportController { + + private final ICaseImportExportService caseExportImportService; + private final CaseExportProperties properties; + + @Operation(summary = "Download xml file containing data of requested cases", security = {@SecurityRequirement(name = "BasicAuth")}) + @GetMapping(produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public ResponseEntity exportCases(@RequestParam("caseIds") Set caseIds, @RequestParam boolean withFiles) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + exportCases(caseIds, outputStream, withFiles); + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE); + String fileName = withFiles ? "case_export.zip" : properties.getFileName(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\""); + return ResponseEntity + .ok() + .headers(headers) + .body(new InputStreamResource(new ByteArrayInputStream(outputStream.toByteArray()))); + } + + private void exportCases(Set caseIds, ByteArrayOutputStream outputStream, boolean withFiles) { + try { + if (withFiles) { + caseExportImportService.findAndExportCasesWithFiles(caseIds, outputStream); + } else { + caseExportImportService.findAndExportCases(caseIds, outputStream); + } + } catch (IOException e) { + log.error("Error occurred during exporting of cases", e); + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/netgrif/application/engine/workflow/web/CaseImportController.java b/src/main/java/com/netgrif/application/engine/workflow/web/CaseImportController.java new file mode 100644 index 00000000000..a97e8b20431 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/workflow/web/CaseImportController.java @@ -0,0 +1,70 @@ +package com.netgrif.application.engine.workflow.web; + +import com.netgrif.application.engine.files.throwable.StorageException; +import com.netgrif.application.engine.workflow.domain.Case; +import com.netgrif.application.engine.workflow.exceptions.ImportXmlFileMissingException; +import com.netgrif.application.engine.workflow.service.interfaces.ICaseImportExportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RestController() +@RequestMapping("/api/case/import") +@AllArgsConstructor +public class CaseImportController { + + private final ICaseImportExportService caseExportImportService; + + @Operation(summary = "Import cases from xml file", security = {@SecurityRequirement(name = "BasicAuth")}) + @PostMapping(produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity importCases(@RequestPart(value = "file") MultipartFile multipartFile) { + List importedCases; + try { + importedCases = caseExportImportService.importCases(multipartFile.getInputStream()); + } catch (IOException e) { + log.error("Error occurred during importing of cases", e); + throw new RuntimeException(e); + } + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE); + return ResponseEntity + .ok() + .headers(headers) + .body(importedCases.stream().map(Case::getStringId).collect(Collectors.joining(","))); + } + + @Operation(summary = "Import cases from zip archive", security = {@SecurityRequirement(name = "BasicAuth")}) + @PostMapping(value = "/withFiles", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity importCasesWithFiles(@RequestPart(value = "zipFile") MultipartFile multipartZipFile) { + List importedCases; + try { + importedCases = caseExportImportService.importCasesWithFiles(multipartZipFile.getInputStream()); + } catch (IOException | StorageException e) { + log.error("Error occurred during importing of cases", e); + throw new RuntimeException(e); + } catch (ImportXmlFileMissingException e) { + log.error("Xml file with cases to import missing from archive file", e); + return ResponseEntity.badRequest().body(e.getMessage()); + } + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE); + return ResponseEntity + .ok() + .headers(headers) + .body(importedCases.stream().map(Case::getStringId).collect(Collectors.joining(","))); + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 9f45be6d199..d1558757d15 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -50,4 +50,7 @@ management.health.mail.enabled=false nae.storage.minio.enabled=true nae.storage.minio.hosts.host_1.host=http://127.0.0.1:9000 nae.storage.minio.hosts.host_1.user=root -nae.storage.minio.hosts.host_1.password=password \ No newline at end of file +nae.storage.minio.hosts.host_1.password=password + +#Schema +nae.schema.location=${SCHEMA_LOCATION:https://petriflow.com/petriflow.schema.xsd} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 526e4683e57..9345721bb6e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -197,3 +197,9 @@ management.endpoint.health.probes.enabled=true logging.file.path=log management.info.build.enabled=false management.info.env.enabled=true + +#Schema +nae.schema.location=https://petriflow.com/petriflow.schema.xsd + +#Case +nae.case.export.file-name=case_export.xml \ No newline at end of file diff --git a/src/main/resources/petriNets/petriflow_schema.xsd b/src/main/resources/petriNets/petriflow_schema.xsd index 28e88a26d6f..716345786ee 100644 --- a/src/main/resources/petriNets/petriflow_schema.xsd +++ b/src/main/resources/petriNets/petriflow_schema.xsd @@ -1,8 +1,1015 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + This attribute is deprecated, use ... instead + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/groovy/com/netgrif/application/engine/workflow/CaseImportExportTest.groovy b/src/test/groovy/com/netgrif/application/engine/workflow/CaseImportExportTest.groovy new file mode 100644 index 00000000000..97a1fceae06 --- /dev/null +++ b/src/test/groovy/com/netgrif/application/engine/workflow/CaseImportExportTest.groovy @@ -0,0 +1,181 @@ +package com.netgrif.application.engine.workflow + +import com.netgrif.application.engine.TestHelper +import com.netgrif.application.engine.auth.service.interfaces.IUserService +import com.netgrif.application.engine.files.StorageResolverService +import com.netgrif.application.engine.files.interfaces.IStorageService +import com.netgrif.application.engine.petrinet.domain.I18nString +import com.netgrif.application.engine.petrinet.domain.dataset.StorageField +import com.netgrif.application.engine.petrinet.domain.dataset.logic.FieldBehavior +import com.netgrif.application.engine.petrinet.service.PetriNetService +import com.netgrif.application.engine.startup.ImportHelper +import com.netgrif.application.engine.startup.runner.SuperCreatorRunner +import com.netgrif.application.engine.workflow.domain.Case +import com.netgrif.application.engine.workflow.service.interfaces.ICaseImportExportService +import com.netgrif.application.engine.workflow.service.interfaces.ITaskService +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.junit.jupiter.SpringExtension + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.Month +import java.time.format.DateTimeFormatter + +@Slf4j +@SpringBootTest +@CompileStatic +@ActiveProfiles(["test"]) +@ExtendWith(SpringExtension.class) +class CaseImportExportTest { + + private final String testNetFileName = "nae_1843.xml" + private final String testNetIdentifier = "data/export_data" + private final String outputFileLocation = "src" + File.separator + "test" + File.separator + "resources" + File.separator + private final String outputFileName = "case_export_test.zip" + private final String outputPath = outputFileLocation + outputFileName + + @Autowired + private SuperCreatorRunner superCreator + + @Autowired + private PetriNetService petriNetService + + @Autowired + private IWorkflowService workflowService + + @Autowired + private TestHelper testHelper + + @Autowired + private ImportHelper importHelper + + @Autowired + private ICaseImportExportService caseImportExportService + + @Autowired + private StorageResolverService resolverService + + @Autowired + private IUserService userService + + @Autowired + private ITaskService taskService + + @BeforeEach + void before() { + testHelper.truncateDbs() + importHelper.createNet(testNetFileName).get() + } + + @Test + void exportCase() { + Case toExport = createCaseAndSetData() + importHelper.assignTaskToSuper("Start task", toExport.stringId) + toExport = importHelper.finishTaskAsSuper("Start task", toExport.stringId).getCase() + File outputFile = new File(outputPath) + try (FileOutputStream fos = new FileOutputStream(outputFile)) { + caseImportExportService.exportCasesWithFiles(List.of(toExport), fos) + assert outputFile.exists() && !outputFile.isDirectory() + assert outputFile.length() > 0 + } catch (IOException e) { + throw new RuntimeException(e) + } + workflowService.deleteCase(toExport) + } + + @Test + void importCase() { + try (FileInputStream fis = new FileInputStream(outputPath)) { + List importedCases = caseImportExportService.importCasesWithFiles(fis) + assert importedCases != null && !importedCases.isEmpty() + Case importedCase = importedCases[0] + assertDataImport(importedCase) + assertDataBehavior(importedCase) + assert importedCase.tasks.size() == 2 + assert importedCase.tasks.collect { it.transition }.containsAll(["t1", "t2"]) + } catch (IOException e) { + throw new RuntimeException(e) + } + } + + Case createCaseAndSetData() { + Case testCase = workflowService.createCaseByIdentifier(testNetIdentifier, "export case", "", superCreator.getLoggedSuper()).getCase() + Map dataSet = [ + "number" : ["type": "number", "value": 24.3], + "number_currency" : ["type": "number", "value": 223], + "text" : ["type": "text", "value": "test text"], + "password_data" : ["type": "text", "value": "password"], + "text_area" : ["type": "text", "value": "text area"], + "enumeration_autocomplete": ["type": "enumeration", "value": "Alice"], + "enumeration_list" : ["type": "enumeration", "value": "Alice"], + "enumeration_map" : ["type": "enumeration_map", "value": "al"], + "multichoice" : ["type": "multichoice", "value": ["Alice", "Carol"]], + "multichoice_list" : ["type": "multichoice", "value": ["Alice", "Carol"]], + "multichoice_map" : ["type": "multichoice_map", "value": ["al", "ca"]], + "boolean" : ["type": "boolean", "value": "true"], + "date" : ["type": "date", "value": LocalDate.of(2025, Month.APRIL, 1).format(DateTimeFormatter.ISO_DATE)], + "datetime" : ["type": "dateTime", "value": LocalDateTime.of(2025, Month.APRIL, 1, 17, 23).format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))], + "taskRef" : ["type": "taskRef", "value": [testCase.tasks.find { it.transition == "t2" }.task]], + "test_i18n" : ["type": "i18n", "value": new I18nString("test i18n")], + "user" : ["type": "user", "value": userService.findByEmail("super@netgrif.com", true).stringId], + "userList1" : ["type": "userList", "value": [userService.findByEmail("super@netgrif.com", true).stringId]], + "button" : ["type": "button", "value": 45], + "caseRef_field" : ["type": "caseRef", "value": [testCase.stringId]], + "stringCollection_field" : ["type": "stringCollection", "value": ["test_value_1", "test_value_2"]], + ] + setFiles(testCase, dataSet) + return importHelper.setTaskData(testCase.tasks.find { it.transition == "1" }.task, dataSet as Map>).getCase() + } + + void setFiles(Case testCase, Map dataSet) { + StorageField fileField = testCase.getField("file") as StorageField + IStorageService fileStorageService = resolverService.resolve(fileField.getStorageType()) + String fileFieldpath = fileStorageService.getPath(testCase.stringId, fileField.stringId, "arc_order_test.xml") + fileStorageService.save(fileField, fileFieldpath, new FileInputStream(new File(outputFileLocation.concat("arc_order_test.xml")))) + dataSet.put("file", ["type": "file", "value": "arc_order_test.xml:".concat(fileFieldpath)]) + } + + static void assertDataImport(Case importedCase) { + assert importedCase.dataSet["number"].value == 24.3 + assert importedCase.dataSet["number_currency"].value == 223 + assert importedCase.dataSet["text"].value == "test text" + assert importedCase.dataSet["password_data"].value == "password" + assert importedCase.dataSet["text_area"].value == "text area" + assert (importedCase.dataSet["enumeration_autocomplete"].value as I18nString).defaultValue == "Alice" + assert (importedCase.dataSet["enumeration"].value as I18nString).defaultValue == "changed_option" + assert importedCase.dataSet["enumeration"].choices[0].defaultValue == "changed_option" + assert (importedCase.dataSet["enumeration_list"].value as I18nString).defaultValue == "Alice" + assert importedCase.dataSet["enumeration_map"].value == "al" + assert (importedCase.dataSet["multichoice"].value as Set).size() == 2 && (importedCase.dataSet["multichoice"].value as Set).stream().filter { ["Alice", "Carol"].contains(it.defaultValue) }.toList().size() == 2 + assert (importedCase.dataSet["multichoice_list"].value as Set).size() == 2 && (importedCase.dataSet["multichoice_list"].value as Set).stream().filter { ["Alice", "Carol"].contains(it.defaultValue) }.toList().size() == 2 + assert (importedCase.dataSet["multichoice_map"].value as Set).containsAll(["al", "ca"]) + assert importedCase.dataSet["boolean"].value == true + assert importedCase.dataSet["date"].value.equals(LocalDate.of(2025, Month.APRIL, 1)) + assert importedCase.dataSet["datetime"].value.equals(LocalDateTime.of(2025, Month.APRIL, 1, 17, 23)) + assert (importedCase.dataSet["taskRef"].value as List).contains(importedCase.tasks.find { it.transition == "t2" }.task) + assert (importedCase.dataSet["test_i18n"].value as I18nString).defaultValue == "test i18n" + assert importedCase.dataSet["user"].value == null + assert importedCase.dataSet["userList1"].value == null + assert importedCase.dataSet["button"].value == 45 + assert (importedCase.dataSet["caseRef_field"].value as List).contains(importedCase.stringId) + assert (importedCase.dataSet["stringCollection_field"].value as List).containsAll(["test_value_1", "test_value_2"]) + assert importedCase.dataSet["caseRef_change_allowed_nets"].allowedNets.containsAll(["changed_allowed_nets"]) + assert importedCase.dataSet["text"].validations.find { validation -> validation.validationRule == "email" } != null + } + + static void assertDataBehavior(Case importedCase) { + assert importedCase.dataSet["number"].behavior.get("t1").size() == 1 && importedCase.dataSet["number"].behavior.get("t1")[0].name() == FieldBehavior.VISIBLE.name() + assert importedCase.dataSet["text"].behavior.get("t1").size() == 2 && importedCase.dataSet["text"].behavior.get("t1").collect { it.name() }.containsAll([FieldBehavior.EDITABLE.name(), FieldBehavior.REQUIRED.name()]) + assert importedCase.dataSet["enumeration"].behavior.get("t1").size() == 1 && importedCase.dataSet["enumeration"].behavior.get("t1")[0].name() == FieldBehavior.HIDDEN.name() + assert importedCase.dataSet["multichoice"].behavior.get("t1").size() == 1 && importedCase.dataSet["multichoice"].behavior.get("t1")[0].name() == FieldBehavior.FORBIDDEN.name() + assert importedCase.dataSet["date"].behavior.get("t1").size() == 2 && importedCase.dataSet["date"].behavior.get("t1").collect { it.name() }.containsAll([FieldBehavior.EDITABLE.name(), FieldBehavior.OPTIONAL.name()]) + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 23248333d50..8d3be165701 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -73,4 +73,7 @@ nae.public.url=test.public.url nae.storage.minio.enabled=true nae.storage.minio.hosts.host_1.host=http://127.0.0.1:9000 nae.storage.minio.hosts.host_1.user=root -nae.storage.minio.hosts.host_1.password=password \ No newline at end of file +nae.storage.minio.hosts.host_1.password=password + +#Schema +nae.schema.location=${SCHEMA_LOCATION:https://petriflow.com/petriflow.schema.xsd} \ No newline at end of file diff --git a/src/test/resources/petriNets/nae_1843.xml b/src/test/resources/petriNets/nae_1843.xml new file mode 100644 index 00000000000..6c4bcc87358 --- /dev/null +++ b/src/test/resources/petriNets/nae_1843.xml @@ -0,0 +1,939 @@ + + data/export_data + EXP + Export data + true + false + false + Export data test default case name + + process_role + Process role + + + boolean + Boolean + True + + + button + Button + Push + + fab + + + + caseRef_field + CaseRef field + + data/export_data + + + + caseRef_change_allowed_nets + CaseRef field + + data/export_data + + + + date + Date + 01.01.2019 + + + datetime + Datetime + 01.01.2019 20:00 + + + enumeration + Enumeration + + + + + + Bob + + + enumeration_autocomplete + Enumeration autocomplete + + + + + + Bob + + autocomplete + + + + enumeration_list + Enumeration list + + + + + + Bob + + list + + + + enumeration_map + Enumeration Map + + + + + + bo + + + file + File + + + fileList + File List + + + multichoice + Multichoice + + + + + + + Alice + Bob + + + + multichoice_list + Multichoice list + + + + + + Alice,Bob + + list + + + + multichoice_map + Multichoice Map + + + + + + + al + ca + + + + number + Number + 10000 + + + number_currency + Number currency + 10000 + + currency + sk_SK + EUR + 2 + + + + password_data + Password from data + + password + + + + password_dataref + Password from dataRef + + + stringCollection_field + StringCollection field + + + taskRef + Task Ref + + 4 + + + + taskref_test_field + Text datafield z iného transitionu + Field načítaný pomocou taskrefu + + + test_i18n + test + test_default + + + text + Text + Lorem ipsum + + + text_area + Text area + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vitae magna in libero semper + vulputate ut eu sapien. Phasellus vel. + + textarea + red + 12 + + + + user + User + + + userList1 + UserList + + + userList2 + UserList + + + test_en + + + test_de + + + 1 + 336 + 272 + + auto + + number + 4 + grid + Number fields + + number + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + text + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + enumeration + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + multichoice + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + boolean + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + date + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + file + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + user + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + button + + editable + + + 0 + 0 + 1 + 2 + + outline + + + 1 + + + + + + + + + taskRef + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + number_currency + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + text_area + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + enumeration_autocomplete + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + multichoice_list + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + datetime + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + fileList + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + password_data + + editable + + + 0 + 2 + 1 + 2 + + outline + + + + enumeration_list + + editable + + + 0 + 2 + 1 + 2 + + outline + + + + multichoice_map + + editable + + + 0 + 2 + 1 + 2 + + outline + + + + password_dataref + + editable + + + 0 + 3 + 1 + 2 + + outline + + + password + + + + enumeration_map + + editable + + + 0 + 3 + 1 + 2 + + outline + + + + + 1 + + + trans: t.t1, + number: f.number, + text: f.text, + enumeration: f.enumeration, + multichoice: f.multichoice, + caseRef_change_allowed_nets: f.caseRef_change_allowed_nets, + date: f.date; + + make number,visible on trans when { true } + make text,required on trans when { true } + make enumeration,hidden on trans when { true } + make multichoice,forbidden on trans when { true } + make date,optional on trans when { true } + + change enumeration options { + [ "changed_option": new com.netgrif.application.engine.petrinet.domain.I18nString("changed_option")] + } + + change enumeration value { new com.netgrif.application.engine.petrinet.domain.I18nString("changed_option") } + + change caseRef_change_allowed_nets allowedNets { + ["changed_allowed_nets"] + } + + change text validations { "email" } + + + + + + t1 + 528 + 272 + + + number + 4 + grid + Number fields + + number + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + text + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + enumeration + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + multichoice + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + boolean + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + date + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + file + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + user + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + button + + editable + + + 0 + 0 + 1 + 2 + + outline + + + 2 + + + + + + + + + taskRef + + editable + + + 0 + 0 + 1 + 2 + + outline + + + + number_currency + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + text_area + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + enumeration_autocomplete + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + multichoice_list + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + datetime + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + fileList + + editable + + + 0 + 1 + 1 + 2 + + outline + + + + password_data + + editable + + + 0 + 2 + 1 + 2 + + outline + + + + enumeration_list + + editable + + + 0 + 2 + 1 + 2 + + outline + + + + multichoice_map + + editable + + + 0 + 2 + 1 + 2 + + outline + + + + password_dataref + + editable + + + 0 + 3 + 1 + 2 + + outline + + + password + + + + enumeration_map + + editable + + + 0 + 3 + 1 + 2 + + outline + + + + + + t2 + 336 + 112 + + + + p1 + 240 + 272 + + 1 + false + + + p2 + 432 + 272 + + 0 + false + + + a1 + regular + p1 + 1 + 1 + + + a2 + regular + 1 + p2 + 1 + + + a3 + regular + p2 + t1 + 1 + + \ No newline at end of file