diff --git a/.trivyignore b/.trivyignore index 5578f148..174aa277 100644 --- a/.trivyignore +++ b/.trivyignore @@ -2,16 +2,14 @@ CVE-2022-27191 CVE-2022-30065 -# Accept the risk for DoS Attack for now and apply dependabot fixes as they arrive (then remove this) -CVE-2022-25857 - # resource exhaustion attack on jackson-databind, a transitive dependency of problem-spring-web-starter (api) remove when problem-spring-web-starter updates dependency to 2.14.0-rc1 or greater CVE-2022-42003 CVE-2022-42004 -# Accept the risk as a transitive dependency of mockserver and so only used for tests remove when upgrade mockserver > 5.14.0 (as yet unrelease) -CVE-2022-42889 +# remove when spring boot > 3 .. risk seems to be of crashing rather than security +CVE-2023-1370 # Even the latest version of springboot-starter-web 3.0.2 have transitive dependencies: tomcat-embed-core-9.0.68.jar and spring-web-5.3.23.jar with these issues CVE-2022-45143 +# remove when spring-web (dependency of spring-boot-starter-web) is 6.0.0 or above CVE-2016-1000027 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..566f4883 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Change Log + +Notable changes in each release are documented below. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +* **Security** - in case of vulnerabilities. +* **Changed** - for changes in existing functionality. +* **Deprecated** - for soon-to-be removed features. +* **Removed** - for now removed features. +* **Fixed** - for any bug fixes. + +## UNRELEASED + +### Security + +- Updated library versions to mitigate CVEs: + - + +#### Changed + +- SNOMED-457: streamline login process (DEX) +- SNOMED-499: expand export to include additional source columns +- SNOMED-500: export notes, author and reviewer + +#### Fixed + +- SNOMED-496: Hiding index column causes filters to be misaligned +- SNOMED-475: Import error never goes away +- SNOMED-470: pre-filled fields in "create map" dialog when they should be empty +- SNOMED-408: Member list in the edit/create map dialogue is only returning X number of users +- SNOMED-489: Select all toggle only selects items on page diff --git a/api/docker-compose.yml b/api/docker-compose.yml index beec01d4..710af7ab 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -1,12 +1,26 @@ version: '3.2' services: + db: + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password + restart: always + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: s2s + ports: + - 3306:3306 + volumes: + - mysql_db:/var/lib/mysql + clair: + depends_on: + - db container_name: snap2snomed_api - image: quay.io/aehrc/snap2snomed:latest + image: quay.io/aehrc/snap2snomed:latest-${USER} environment: - snap2snomed.cors.allowedOriginPatterns=* - snap2snomed.cors.allowedHeaders=* - - snap2snomed.cors.allowedMethods=OPTIONS,GET,POST,PUT,PATCH + - snap2snomed.cors.allowedMethods=OPTIONS,GET,POST,PUT,PATCH,DELETE - snap2snomed.cors.maxAge=3600 - snap2snomed.swagger.applicationVersion=0.1.0-SNAPSHOT - snap2snomed.swagger.applicationDescription=API Backend for the Snap2Snomed mapping tool @@ -18,10 +32,13 @@ services: - snap2snomed.swagger.contactEmail=ontoserver-support@csiro.au - snap2snomed.swagger.contactUrl=https://aehrc.com/ - snap2snomed.security.authDomainUrl=https://snap-2-snomed-test.auth.ap-southeast-2.amazoncognito.com + - snap2snomed.security.clientId=v597lp3lk3ue2qtks5jb41la6 - snap2snomed.defaultTerminologyServer.url=https://r4.ontoserver.csiro.au/fhir - - spring.datasource.url=jdbc:h2:/opt/snap2snomed/db - - snap2snomed.security.adminGroup=AdminGroup + - spring.datasource.driverClassName=software.aws.rds.jdbc.mysql.Driver + - spring.datasource.url=jdbc:mysql:aws://db:3306/s2s?cachePrepStmts=true&useServerPrepStmts=false&rewriteBatchedStatements=true&socketTimeout=480000 + - spring.security.oauth2.resourceserver.jwt.issuer-uri=https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_oQSXJHFz9 ports: - "8080:8080" - volumes: - - /opt/snap2snomed/db:/opt/snap2snomed/db \ No newline at end of file +volumes: + mysql_db: + diff --git a/api/pom.xml b/api/pom.xml index 29b88e89..ff71df59 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -30,30 +30,40 @@ API Backend for the Snap2Snomed mapping tool - 11 + 17 aehrc/snap2snomed quay.io - 6.4.2 + 6.32.0 0.8.8 - 1.9.0 - 1.1.2 - 1.6.12 + 1.10.0 + 1.1.10 + 1.6.15 3.3.1 1.1.3 0.27.0 - 2.28.2 - 5.2.2 - 4.4.0 + 2.34.0 + 5.2.3 + 4.5.1 0.11.5 1.17.2 - 5.14.0 + 5.15.0 1.3 2.0.8 - 6.0.2 - 5.7.5 - 5.3.21 + 6.6.2 + 6.1.5 + 5.3.29 + + + + org.apache.commons + commons-text + 1.10.0 + + + + org.springframework.boot @@ -154,17 +164,16 @@ spring-security-test test - - com.h2database - h2 - test - org.springframework.boot spring-boot-configuration-processor true - + + org.mariadb.jdbc + mariadb-java-client + 3.2.0 + org.apache.commons commons-csv @@ -243,6 +252,13 @@ ${mockserver.version} test + + + com.google.guava + guava + 32.1.3-jre + + org.exparity hamcrest-date @@ -283,6 +299,20 @@ + + org.apache.maven.plugins + maven-help-plugin + 3.4.0 + + + show-profiles + compile + + active-profiles + + + + org.apache.maven.plugins maven-failsafe-plugin @@ -312,7 +342,7 @@ jib-maven-plugin ${jib.version} - aehrc/jre:openjdk-11-fontconfig + aehrc/jre:openjdk-17-fontconfig 8080 @@ -325,19 +355,11 @@ ${docker.registry.host}/${docker.repository} - ${project.version} - latest + ${project.version}-${user.name} + latest-${user.name} - - - package - - dockerBuild - - - @@ -400,7 +422,7 @@ LINE COVEREDRATIO - 0.0 @@ -425,17 +447,96 @@ - - h2 - - - com.h2database - h2 - compile - - + docker + + !multi + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib.version} + + + package + + dockerBuild + + + + + + + + + + arm + + !multi + + aarch64 + + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib.version} + + + + + arm64 + linux + + + + + + + + + + + multi + + multi + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib.version} + + + + + arm64 + linux + + + amd64 + linux + + + + + + + package + + build + + + + + + diff --git a/api/src/main/java/org/snomed/snap2snomed/Snap2snomedApplication.java b/api/src/main/java/org/snomed/snap2snomed/Snap2snomedApplication.java index 3ac5635e..c2e68fd9 100644 --- a/api/src/main/java/org/snomed/snap2snomed/Snap2snomedApplication.java +++ b/api/src/main/java/org/snomed/snap2snomed/Snap2snomedApplication.java @@ -16,11 +16,6 @@ package org.snomed.snap2snomed; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.info.License; import org.snomed.snap2snomed.config.Snap2snomedConfiguration; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; @@ -30,6 +25,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.data.envers.repository.support.EnversRevisionRepositoryFactoryBean; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.web.filter.CommonsRequestLoggingFilter; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; @SpringBootApplication(exclude = ErrorMvcAutoConfiguration.class) @EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class) @@ -42,7 +44,7 @@ public class Snap2snomedApplication { public static void main(String[] args) { SpringApplication.run(Snap2snomedApplication.class, args); } - + /** * Swagger ui customisation */ diff --git a/api/src/main/java/org/snomed/snap2snomed/config/SecurityConfiguration.java b/api/src/main/java/org/snomed/snap2snomed/config/SecurityConfiguration.java index fe30220d..1ad5ef75 100644 --- a/api/src/main/java/org/snomed/snap2snomed/config/SecurityConfiguration.java +++ b/api/src/main/java/org/snomed/snap2snomed/config/SecurityConfiguration.java @@ -17,9 +17,11 @@ package org.snomed.snap2snomed.config; import javax.validation.constraints.NotBlank; + +import org.snomed.snap2snomed.validation.AtLeastOneNotNull; + import lombok.Getter; import lombok.Setter; -import org.snomed.snap2snomed.validation.AtLeastOneNotNull; @Getter @Setter diff --git a/api/src/main/java/org/snomed/snap2snomed/config/Snap2snomedConfiguration.java b/api/src/main/java/org/snomed/snap2snomed/config/Snap2snomedConfiguration.java index c55e5e70..039dabf3 100644 --- a/api/src/main/java/org/snomed/snap2snomed/config/Snap2snomedConfiguration.java +++ b/api/src/main/java/org/snomed/snap2snomed/config/Snap2snomedConfiguration.java @@ -86,4 +86,5 @@ public class Snap2snomedConfiguration { @Valid SwaggerConfiguration swagger = new SwaggerConfiguration(); + String identityProvider = ""; } diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/ImportedCodeSetRestController.java b/api/src/main/java/org/snomed/snap2snomed/controller/ImportedCodeSetRestController.java index 7458f98f..dba7ccc2 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/ImportedCodeSetRestController.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/ImportedCodeSetRestController.java @@ -18,13 +18,10 @@ import org.snomed.snap2snomed.controller.dto.ImportDetails; import org.snomed.snap2snomed.model.ImportedCodeSet; -import org.snomed.snap2snomed.model.Project; import org.snomed.snap2snomed.problem.auth.NoSuchUserProblem; import org.snomed.snap2snomed.security.WebSecurity; import org.snomed.snap2snomed.service.CodeSetImportService; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.rest.webmvc.RepositoryRestController; -import org.springframework.hateoas.server.ExposesResourceFor; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.HttpMediaTypeNotAcceptableException; diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/MapViewRestController.java b/api/src/main/java/org/snomed/snap2snomed/controller/MapViewRestController.java index 821bb27e..6fd0e6fc 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/MapViewRestController.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/MapViewRestController.java @@ -19,7 +19,13 @@ import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStreamWriter; +import java.util.Date; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; +import java.util.Map; import javax.servlet.http.HttpServletResponse; @@ -29,15 +35,28 @@ import org.apache.poi.xssf.streaming.SXSSFRow; import org.apache.poi.xssf.streaming.SXSSFSheet; import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.hl7.fhir.r4.model.ConceptMap; +import org.hl7.fhir.r4.model.ConceptMap.ConceptMapGroupComponent; +import org.hl7.fhir.r4.model.ConceptMap.SourceElementComponent; +import org.hl7.fhir.r4.model.ConceptMap.TargetElementComponent; +import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence; +import org.hl7.fhir.r4.model.Enumerations.PublicationStatus; +import org.hl7.fhir.r4.model.UriType; import org.snomed.snap2snomed.controller.dto.Snap2SnomedPagedModel; +import org.snomed.snap2snomed.model.ImportedCodeSet; +import org.snomed.snap2snomed.model.AdditionalCodeValue; import org.snomed.snap2snomed.model.MapView; +import org.snomed.snap2snomed.model.Project; import org.snomed.snap2snomed.model.enumeration.MapStatus; import org.snomed.snap2snomed.model.enumeration.MappingRelationship; import org.snomed.snap2snomed.problem.auth.NoSuchUserProblem; import org.snomed.snap2snomed.problem.auth.NotAuthorisedProblem; +import org.snomed.snap2snomed.repository.MapRepository; import org.snomed.snap2snomed.security.WebSecurity; +import org.snomed.snap2snomed.service.FhirService; import org.snomed.snap2snomed.service.MapViewService; import org.snomed.snap2snomed.service.MapViewService.MapViewFilter; +import org.snomed.snap2snomed.service.TerminologyProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PagedResourcesAssembler; @@ -53,6 +72,7 @@ import org.zalando.problem.Problem; import org.zalando.problem.Status; +import ca.uhn.fhir.parser.IParser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -71,8 +91,7 @@ public class MapViewRestController { public static final String TEXT_CSV = "text/csv"; - public static final String[] EXPORT_HEADER = {"\ufeff" + "Source code", "Source display", "Target code", "Target display", "Relationship type code", - "Relationship type display", "No map flag", "Status"}; + public static final String FHIR_JSON = "application/fhir+json"; @Autowired MapViewService mapViewService; @@ -80,6 +99,9 @@ public class MapViewRestController { @Autowired WebSecurity webSecurity; + @Autowired + TerminologyProvider terminology; + @Operation(description = "Returns a flattened view of the MapRows and MapRowTargets for the specified mapId.") @Parameter(name = "mapId", in = ParameterIn.PATH, required = true, allowEmptyValue = false, description = "Id of the map the view is to be generated for") @@ -109,6 +131,10 @@ public class MapViewRestController { description = "Filters the results to those that are assigned to an author task assigned to one of the specified user ids.") @Parameter(name = "assignedReviewer", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, description = "Filters the results to those that are assigned to an review task assigned to one of the specified user ids.") + @Parameter(name = "assignedReconciler", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, + description = "Filters the results to those that are assigned to an reconcile task assigned to one of the specified user ids.") + @Parameter(name = "targetOutOfScope", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, + description = "Filters the results to those that have a target out of scope or not.") @Parameter(name = "flagged", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, description = "Filters the results to those that are flagged or not flagged.") @Parameter(name = "additionalColumns", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, @@ -135,6 +161,8 @@ public ResponseEntity>> getMapView( @RequestParam(required = false) List lastAuthorReviewer, @RequestParam(required = false) List assignedAuthor, @RequestParam(required = false) List assignedReviewer, + @RequestParam(required = false) List assignedReconciler, + @RequestParam(required = false) Boolean targetOutOfScope, @RequestParam(required = false) Boolean flagged, @RequestParam(required = false) List additionalColumns, @Parameter(hidden = true) Pageable pageable, @@ -148,7 +176,8 @@ public ResponseEntity>> getMapView( } final MapViewFilter filter = mapViewService.new MapViewFilter(sourceCode, sourceDisplay, noMap, targetCode, targetDisplay, relationship, - status, lastAuthor, lastReviewer, lastAuthorReviewer, assignedAuthor, assignedReviewer, flagged, additionalColumns); + status, lastAuthor, lastReviewer, lastAuthorReviewer, assignedAuthor, assignedReviewer, assignedReconciler, + targetOutOfScope, flagged, additionalColumns); return ResponseEntity.ok(mapViewService.getMapResults(mapId, pageable, assembler, filter)); } @@ -182,6 +211,10 @@ public ResponseEntity>> getMapView( description = "Filters the results to those that are assigned to an author task assigned to one of the specified user ids.") @Parameter(name = "assignedReviewer", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, description = "Filters the results to those that are assigned to an review task assigned to one of the specified user ids.") + @Parameter(name = "assignedReconciler", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, + description = "Filters the results to those that are assigned to a reconcile task assigned to one of the specified user ids") + @Parameter(name = "targetOutOfScope", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, + description = "Filters the results to those that have a target out of scope or not.") @Parameter(name = "flagged", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, description = "Filters the results to those that are flagged or not flagged.") @Parameter(name = "additionalColumns", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, @@ -208,13 +241,15 @@ public ResponseEntity>> getTaskView( @RequestParam(required = false) List lastAuthorReviewer, @RequestParam(required = false) List assignedAuthor, @RequestParam(required = false) List assignedReviewer, + @RequestParam(required = false) List assignedReconciler, + @RequestParam(required = false) Boolean targetOutOfScope, @RequestParam(required = false) Boolean flagged, @RequestParam(required = false) List additionalColumns, @Parameter(hidden = true) Pageable pageable, @Parameter(hidden = true) PagedResourcesAssembler assembler) { final MapViewFilter filter = mapViewService.new MapViewFilter(sourceCode, sourceDisplay, noMap, targetCode, targetDisplay, relationship, - status, lastAuthor, lastReviewer, lastAuthorReviewer, assignedAuthor, assignedReviewer, flagged, additionalColumns); + status, lastAuthor, lastReviewer, lastAuthorReviewer, assignedAuthor, assignedReviewer, assignedReconciler, targetOutOfScope, flagged, additionalColumns); if (!webSecurity.isValidUser()) { throw new NoSuchUserProblem(); @@ -230,9 +265,12 @@ public ResponseEntity>> getTaskView( @Parameter(name = "sort", in = ParameterIn.QUERY, required = false, allowEmptyValue = false, description = "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", array = @ArraySchema(schema = @Schema(type = "string"))) + @Parameter(name="extraColumns", in = ParameterIn.QUERY, required = false, allowEmptyValue = false, + description = "Additional columns to include. Options are ( notes | lastAuthor | lastReviewer | assignedAuthor | assignedReviewer ).") @GetMapping(path = "/{mapId}", produces = {TEXT_CSV, TEXT_TSV}) public void getMapViewCsv(HttpServletResponse response, @RequestHeader(name = "Accept", required = false) String contentType, - @PathVariable("mapId") Long mapId) { + @PathVariable("mapId") Long mapId, + @RequestParam(required = false) List extraColumns) { if (!webSecurity.isValidUser()) { throw new NoSuchUserProblem(); @@ -267,14 +305,52 @@ public void getMapViewCsv(HttpServletResponse response, @RequestHeader(name = "A new OutputStreamWriter(response.getOutputStream())); CSVPrinter csvPrinter = new CSVPrinter(writer, - format.builder().setHeader(EXPORT_HEADER).build());) { + format.builder().setHeader(mapViewService.getExportHeader(mapId, extraColumns)).build());) { + for (final MapView mapView : mapViewService.getAllMapViewForMap(mapId)) { - csvPrinter.printRecord( - mapView.getSourceCode(), mapView.getSourceDisplay(), - mapView.getTargetCode(), mapView.getTargetDisplay(), + + ArrayList printRow = new ArrayList(Arrays.asList(mapView.getSourceCode(), mapView.getSourceDisplay())); + + // additional source columns + if (mapView.getAdditionalColumns() != null) { + if (mapView.getAdditionalColumns().size() > 0) { + for (int i = 0; i < mapView.getAdditionalColumns().size(); i++) { + final AdditionalCodeValue additionalColumn = mapView.getAdditionalColumns().get(i); + printRow.add(additionalColumn.getValue()); + } + } + } + + printRow.addAll(Arrays.asList(mapView.getTargetCode(), mapView.getTargetDisplay(), mapView.getRelationship(), mapView.getRelationship() == null ? "" : mapView.getRelationship().getLabel(), mapView.getNoMap() == null ? "" : mapView.getNoMap(), - mapView.getStatus()); + mapView.getStatus())); + + if (extraColumns != null && extraColumns.size() > 0) { + for (String extraColumn : extraColumns) { + switch (extraColumn.toUpperCase()) { + case "NOTES": + printRow.add(mapView.getAppendedNotes()); + break; + case "ASSIGNEDAUTHOR": + printRow.add(mapView.getAssignedAuthor() == null ? "" : mapView.getAssignedAuthor() + .stream() + .map(author -> author.getFullName()) + .collect(Collectors.joining(","))); + break; + case "ASSIGNEDREVIEWER": + printRow.add(mapView.getAssignedReviewer() == null ? "" : mapView.getAssignedReviewer().getFullName()); + break; + case "LASTAUTHOR": + printRow.add(mapView.getLastAuthor() == null ? "" : mapView.getLastAuthor().getFullName()); + break; + case "LASTREVIEWER": + printRow.add(mapView.getLastReviewer() == null ? "" : mapView.getLastReviewer().getFullName()); + break; + } + } + } + csvPrinter.printRecord(printRow); } csvPrinter.flush(); writer.flush(); @@ -290,8 +366,12 @@ public void getMapViewCsv(HttpServletResponse response, @RequestHeader(name = "A @Parameter(name = "sort", in = ParameterIn.QUERY, required = false, allowEmptyValue = false, description = "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", array = @ArraySchema(schema = @Schema(type = "string"))) + @Parameter(name="extraColumns", in = ParameterIn.QUERY, required = false, allowEmptyValue = false, + description = "Additional columns to include. Options are ( notes | lastAuthor | lastReviewer | assignedAuthor | assignedReviewer ).") @GetMapping(path = "/{mapId}", produces = APPLICATION_XSLX) - public void getMapViewExcel(HttpServletResponse response, @PathVariable("mapId") Long mapId) throws IOException { + public void getMapViewExcel(HttpServletResponse response, + @PathVariable("mapId") Long mapId, + @RequestParam(required = false) List extraColumns) throws IOException { if (!webSecurity.isValidUser()) { throw new NoSuchUserProblem(); } @@ -310,9 +390,11 @@ public void getMapViewExcel(HttpServletResponse response, @PathVariable("mapId") final SXSSFSheet sh = wb.createSheet(); final SXSSFRow header = sh.createRow(0); - for (int cellNum = 0; cellNum < EXPORT_HEADER.length; cellNum++) { + final String[] exportHeader = mapViewService.getExportHeader(mapId, extraColumns); + + for (int cellNum = 0; cellNum < exportHeader.length; cellNum++) { final SXSSFCell cell = header.createCell(cellNum); - cell.setCellValue(EXPORT_HEADER[cellNum]); + cell.setCellValue(exportHeader[cellNum]); } for (int rownum = 1; rownum <= mapViewResult.size(); rownum++) { @@ -326,25 +408,69 @@ public void getMapViewExcel(HttpServletResponse response, @PathVariable("mapId") cell = row.createCell(1); cell.setCellValue(mapView.getSourceDisplay()); - cell = row.createCell(2); + int cellCount = 2; + // additional source columns + if (mapView.getAdditionalColumns() != null) { + for (int i = 0; i < mapView.getAdditionalColumns().size(); i++) { + cell = row.createCell(cellCount); + final AdditionalCodeValue additionalColumn = mapView.getAdditionalColumns().get(i); + cell.setCellValue(additionalColumn.getValue()); + cellCount++; + } + } + + cell = row.createCell(cellCount); cell.setCellValue(mapView.getTargetCode()); + cellCount++; - cell = row.createCell(3); + cell = row.createCell(cellCount); cell.setCellValue(mapView.getTargetDisplay()); + cellCount++; - cell = row.createCell(4); + cell = row.createCell(cellCount); cell.setCellValue(mapView.getRelationship() == null ? "" : mapView.getRelationship().toString()); + cellCount++; - cell = row.createCell(5); + cell = row.createCell(cellCount); cell.setCellValue(mapView.getRelationship() == null ? "" : mapView.getRelationship().getLabel()); + cellCount++; - cell = row.createCell(6); + cell = row.createCell(cellCount); if (mapView.getNoMap() != null) { cell.setCellValue(mapView.getNoMap()); } + cellCount++; - cell = row.createCell(7); + cell = row.createCell(cellCount); cell.setCellValue(mapView.getStatus() == null ? "" : mapView.getStatus().toString()); + cellCount++; + + if (extraColumns != null && extraColumns.size() > 0) { + for (String extraColumn : extraColumns) { + cell = row.createCell(cellCount); + switch (extraColumn.toUpperCase()) { + case "NOTES": + cell.setCellValue(mapView.getAppendedNotes()); + break; + case "ASSIGNEDAUTHOR": + cell.setCellValue(mapView.getAssignedAuthor() == null ? "" : mapView.getAssignedAuthor() + .stream() + .map(author -> author.getFullName()) + .collect(Collectors.joining(","))); + break; + case "ASSIGNEDREVIEWER": + cell.setCellValue(mapView.getAssignedReviewer() == null ? "" : mapView.getAssignedReviewer().getFullName()); + break; + case "LASTAUTHOR": + cell.setCellValue(mapView.getLastAuthor() == null ? "" : mapView.getLastAuthor().getFullName()); + break; + case "LASTREVIEWER": + cell.setCellValue(mapView.getLastReviewer() == null ? "" : mapView.getLastReviewer().getFullName()); + break; + } + cellCount++; + } + } } wb.write(response.getOutputStream()); @@ -353,5 +479,147 @@ public void getMapViewExcel(HttpServletResponse response, @PathVariable("mapId") } } + @Operation(description = "Returns a flattened view of the MapRows and MapRowTargets for the specified mapId.") + @Parameter(name = "page", in = ParameterIn.QUERY, required = false, allowEmptyValue = false, description = "Zero-based page index (0..N)") + @Parameter(name = "size", in = ParameterIn.QUERY, required = false, allowEmptyValue = false, + description = "The size of the page to be returned") + @Parameter(name = "sort", in = ParameterIn.QUERY, required = false, allowEmptyValue = false, + description = "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + array = @ArraySchema(schema = @Schema(type = "string"))) + @GetMapping(path = "/{mapId}", produces = {FHIR_JSON}) + public void getMapViewConceptMapJson(HttpServletResponse response, @RequestHeader(name = "Accept", required = false) String contentType, + @PathVariable("mapId") Long mapId) { + + if (!webSecurity.isValidUser()) { + throw new NoSuchUserProblem(); + } + if (!webSecurity.isAdminUser() && !webSecurity.hasAnyProjectRoleForMapId(mapId)) { + throw new NotAuthorisedProblem("Not authorised to view map if the user is not admin or member of an associated project!"); + } + + response.setContentType(FHIR_JSON); + response.setHeader("Content-Disposition", + "attachment; filename=\"" + mapViewService.getFileNameForMapExport(mapId, FHIR_JSON) + + "\""); + + + final ConceptMap cm = convertToConceptMap(mapId); + + try (final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(response.getOutputStream()))) { + final IParser parser = terminology.getFhirContext().newJsonParser(); + parser.encodeResourceToWriter(cm, writer); + writer.flush(); + } catch (final IOException e) { + throw Problem.builder().withDetail("IO error exporting").build(); + } + } + + @Autowired + private MapRepository mapRepo; + + private ConceptMap convertToConceptMap(Long mapId) { + final Map mapEntry = new HashMap<>(); + final ConceptMap cm = new ConceptMap(); + final ConceptMapGroupComponent group = cm.addGroup(); + + mapRepo.findById(mapId).ifPresent(map -> { + final Project project = map.getProject(); + final String title = project.getTitle(); + cm.setStatus(PublicationStatus.UNKNOWN); + cm.setDate(Date.from(map.getModified())); + cm.setTitle(title); + cm.setName(title.replaceAll("[^\\w\\d]", "_")); + cm.setDescription(project.getDescription()); + cm.setVersion(map.getMapVersion()); + + final ImportedCodeSet source = map.getSource(); + String valuesetUri = source.getValuesetUri(); + if (null != valuesetUri) { + cm.setSource(new UriType(valuesetUri)); + } + + final String ecl = map.getToScope(); + cm.setTarget(new UriType(FhirService.DEFAULT_CODE_SYSTEM + "?fhir_vs=ecl/" + ecl)); + + String systemUri = source.getSystemUri(); + if (null != systemUri) { + group.setSource(systemUri); + } + group.setSourceVersion(source.getVersion()); + group.setTarget(FhirService.DEFAULT_CODE_SYSTEM); + group.setTargetVersion(map.getToVersion()); + }); + + for (final MapView mapView : mapViewService.getAllMapViewForMap(mapId)) { + final Long key = mapView.getSourceIndex(); + final SourceElementComponent src; + if (!mapEntry.containsKey(key)) { + src = group.addElement(); + src.setCode(mapView.getSourceCode()); + src.setDisplay(mapView.getSourceDisplay()); + + mapEntry.put(key, src); + } else { + src = mapEntry.get(key); + } + + final TargetElementComponent tgt = src.addTarget(); + if (null != mapView.getNoMap() && mapView.getNoMap()) { + tgt.setEquivalence(ConceptMapEquivalence.UNMATCHED); + } else { + tgt.setCode(mapView.getTargetCode()); + tgt.setDisplay(mapView.getTargetDisplay()); + if (null != mapView.getRelationship()) { + switch (mapView.getRelationship()) { + case TARGET_EQUIVALENT: + tgt.setEquivalence(ConceptMapEquivalence.EQUIVALENT); + break; + case TARGET_BROADER: + tgt.setEquivalence(ConceptMapEquivalence.WIDER); + break; + case TARGET_NARROWER: + tgt.setEquivalence(ConceptMapEquivalence.NARROWER); + break; + case TARGET_INEXACT: + tgt.setEquivalence(ConceptMapEquivalence.RELATEDTO); + break; + } + } + + if (mapView.getNoMap()) { + tgt.setEquivalence(ConceptMapEquivalence.UNMATCHED); + } + + } + } + return cm; + } + + @Operation(description = "Returns a flattened view of the MapRow and MapRowTargets for sibling of the specified mapRow.") + @Parameter(name = "mapId", in = ParameterIn.PATH, required = true, allowEmptyValue = false, + description = "Id of the map the view is to be generated for") + @Parameter(name = "sourceCodeId", in = ParameterIn.QUERY, required = false, allowEmptyValue = true, + description = "Filters the results by ensuring source codes start with the specified text. ") + @Parameter(name = "mapRowId", in = ParameterIn.QUERY, required = true, allowEmptyValue = false, + description = "Id of the map row that is the sibling of the map row that the view is to be generated for") + @GetMapping(path = "/{mapId}/$dualMapSiblingRow", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getDualMapSiblingRow( + @PathVariable("mapId") Long mapId, + @RequestParam(required = true) Long sourceCodeId, + @RequestParam(required = true) Long mapRowId) { + + //TODO work out what security is required here + if (!webSecurity.isValidUser()) { + throw new NoSuchUserProblem(); + } + if (!webSecurity.isAdminUser() && !webSecurity.hasAnyProjectRoleForMapId(mapId)) { + throw new NotAuthorisedProblem( + "Not authorised to view map if the user is not admin or member of an associated project!"); + } + + MapView result = mapViewService.getDualMapSiblingRow(mapId, sourceCodeId, mapRowId); + return ResponseEntity.ok(result); + } } + diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/MappingRestController.java b/api/src/main/java/org/snomed/snap2snomed/controller/MappingRestController.java index 7af27708..f1bb6b63 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/MappingRestController.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/MappingRestController.java @@ -97,14 +97,23 @@ MappingResponse updateMapping(@RequestBody MappingUpdateDto mappings) { @Parameter(name = "mappingUpdates", in = ParameterIn.QUERY, required = true, allowEmptyValue = false, description = "Details of the bulk change to make") @PostMapping(value = "/updateMapping/map/{mapId}", consumes = "application/json") - MappingResponse updateMappingForMap(@PathVariable("mapId") Long mapId, @RequestBody MappingDto mappingUpdates) { + MappingResponse updateMappingForMap(@PathVariable("mapId") Long mapId, @RequestBody MappingUpdateDto mappingUpdates) { + if (!webSecurity.isValidUser()) { throw new NoSuchUserProblem(); } if (!(webSecurity.isAdminUser() || webSecurity.hasAnyProjectRoleForMapId(mapId))) { throw new NotAuthorisedProblem("Not authorised to bulk update mapping if the user is not admin or member of an associated project!"); } - return mappingService.updateMappingForMap(mapId, mappingUpdates); + // Associated Project role checking happens in the service + return mappingService.updateMappingForAll(mapId, mappingUpdates); + + // 20/01/2023 DU Below is the previous implementation which was never called by the UI. I needed similar functionality, but this implementation + // does not update the target and it goes about things in a very different way so I have decided to take over /updateMapping/map/{mapId} + // so as to not have to create new DTOs to package up a mapId and a MappingUpdateDto together. The new method mimics the approach of + // updateMappingForSelection. + // TODO: clean up updateMappingForMap / updateMappingForTask + // return mappingService.updateMappingForMap(mapId, mappingUpdates); } @Operation(description = "Applies a bulk change to a task specified by a MappingDto, " diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/TaskRestController.java b/api/src/main/java/org/snomed/snap2snomed/controller/TaskRestController.java index a8c5919c..bcd855bb 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/TaskRestController.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/TaskRestController.java @@ -105,9 +105,11 @@ public void completeTask(@PathVariable Long taskId) { Instant modified = Instant.now(); if (task.getType().equals(TaskType.AUTHOR)) { mapRowRepository.setAuthorTaskToNull(task, modified, principalSubject); - } else { + } else if (task.getType().equals(TaskType.REVIEW)) { mapRowRepository.setReviewTaskToNull(task, modified, principalSubject); - } + } else if (task.getType().equals(TaskType.RECONCILE)) { + mapRowRepository.setReconcileTaskToNull(task, modified, principalSubject); + } taskRepository.delete(task); } @@ -134,6 +136,8 @@ private IndexSpecification getIncompleteRows(Task task) { statuses = MapStatus.getCompletedAuthorStatuses(); } else if (task.getType().equals(TaskType.REVIEW)) { statuses = MapStatus.getCompletedReviewStatuses(); + } else if (task.getType().equals(TaskType.RECONCILE)) { + statuses = MapStatus.getCompletedReconcileStatuses(); } else { throw new IllegalArgumentException("Unexpected task type " + task.getType()); } diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/UserInterfaceConfigurationRestController.java b/api/src/main/java/org/snomed/snap2snomed/controller/UserInterfaceConfigurationRestController.java index e683da29..f490619a 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/UserInterfaceConfigurationRestController.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/UserInterfaceConfigurationRestController.java @@ -16,8 +16,8 @@ package org.snomed.snap2snomed.controller; -import io.swagger.v3.oas.annotations.Operation; import javax.validation.Valid; + import org.snomed.snap2snomed.Snap2snomedVersion; import org.snomed.snap2snomed.config.SecurityConfiguration; import org.snomed.snap2snomed.config.Snap2snomedConfiguration; @@ -29,6 +29,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; + @RestController public class UserInterfaceConfigurationRestController { @Autowired @@ -52,9 +54,9 @@ public class UserInterfaceConfigurationRestController { @Operation(description = "Returns configuration information for front end applications connecting to the server.") @GetMapping("/config") public @Valid UserInterfaceConfigurationDetails getConfiguration() { - SecurityConfiguration security = config.getSecurity(); + final SecurityConfiguration security = config.getSecurity(); - UserInterfaceConfigurationDetailsBuilder builder = UserInterfaceConfigurationDetails.builder() + final UserInterfaceConfigurationDetailsBuilder builder = UserInterfaceConfigurationDetails.builder() .appName(config.getApplicationInstanceName()) .authClientID(security.getClientId()) .authDomainUrl(security.getAuthDomainUrl()) @@ -75,7 +77,8 @@ public class UserInterfaceConfigurationRestController { .userRegistrationUrl(config.getUserRegistrationUrl()) .registrationText(config.getRegistrationText()) .mainPageText(config.getMainPageText()) - .currentTermsVersion(config.getCurrentTermsVersion()); + .currentTermsVersion(config.getCurrentTermsVersion()) + .identityProvider(config.getIdentityProvider()); version.getShortGitCommit().ifPresent(builder::sentryRelease); diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/dto/ImportDetails.java b/api/src/main/java/org/snomed/snap2snomed/controller/dto/ImportDetails.java index 245591cd..791ef5b4 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/dto/ImportDetails.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/dto/ImportDetails.java @@ -42,6 +42,12 @@ public class ImportDetails { @Size(min = 1, max = 30, message = "Version must be between 1 and 30 characters") String version; + @Size(min = 1, max = 255, message = "CodeSystem URI must be between 1 and 255 characters") + String codesystemUri; + + @Size(min = 1, max = 255, message = "ValueSet URI must be between 1 and 255 characters") + String valuesetUri; + @NotNull(message = "An index for the codes in a code set import file must be specified") Integer codeColumnIndex; @@ -61,6 +67,8 @@ public ImportedCodeSet toImportedCodeSetEntity(List headerNames) { codeset.setName(name); codeset.setVersion(version); + codeset.setSystemUri(codesystemUri); + codeset.setValuesetUri(valuesetUri); if (null != additionalColumnIndexes) { final List additionalColumns = diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/dto/MappingDto.java b/api/src/main/java/org/snomed/snap2snomed/controller/dto/MappingDto.java index 8cfc90b3..0c6abefd 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/dto/MappingDto.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/dto/MappingDto.java @@ -37,6 +37,8 @@ public class MappingDto { private Boolean clearTarget; + private boolean resetDualMap; + public boolean isNoMap() { return noMap != null && noMap; } @@ -46,13 +48,14 @@ public boolean isOnlyStatusChange() { } public boolean isNoChange() { - return noMap == null && status == null && relationship == null && clearTarget == null; + return noMap == null && status == null && relationship == null && clearTarget == null && resetDualMap == false; } public boolean isValid() { - return ((noMap != null && noMap) && status == null && relationship == null && clearTarget == null) - || ((noMap != null && !noMap) && clearTarget == null) - || (noMap == null && (status != null || relationship != null) && clearTarget == null) - || (noMap == null && status == null && relationship == null && clearTarget != null); + return ((noMap != null && noMap) && status == null && relationship == null && clearTarget == null && resetDualMap == false) + || ((noMap != null && !noMap) && clearTarget == null && resetDualMap == false) + || (noMap == null && (status != null || relationship != null) && clearTarget == null && resetDualMap == false) + || (noMap == null && status == null && relationship == null && clearTarget != null && resetDualMap == false) + || (noMap == null && status == null && relationship == null && clearTarget == null && resetDualMap); } } diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/dto/ProjectDto.java b/api/src/main/java/org/snomed/snap2snomed/controller/dto/ProjectDto.java index 0099c858..dd280c89 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/dto/ProjectDto.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/dto/ProjectDto.java @@ -33,6 +33,9 @@ public class ProjectDto { @NotNull private List<@NotNull Map> maps; + @NotNull + private Boolean dualMapMode; + @NotNull private Set<@NotNull User> owners; @@ -48,6 +51,7 @@ public ProjectDto(Project project) { this.description = project.getDescription(); this.maps = project.getMaps(); this.mapCount = project.getMaps().size(); + this.dualMapMode = project.getDualMapMode(); this.owners = project.getOwners(); this.members = project.getMembers(); this.guests = project.getGuests(); diff --git a/api/src/main/java/org/snomed/snap2snomed/controller/dto/UserInterfaceConfigurationDetails.java b/api/src/main/java/org/snomed/snap2snomed/controller/dto/UserInterfaceConfigurationDetails.java index cdc6796a..245fe244 100644 --- a/api/src/main/java/org/snomed/snap2snomed/controller/dto/UserInterfaceConfigurationDetails.java +++ b/api/src/main/java/org/snomed/snap2snomed/controller/dto/UserInterfaceConfigurationDetails.java @@ -18,9 +18,11 @@ import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; + +import org.hibernate.validator.constraints.URL; + import lombok.Builder; import lombok.Value; -import org.hibernate.validator.constraints.URL; @Value @Builder @@ -88,4 +90,6 @@ public class UserInterfaceConfigurationDetails { @NotBlank String currentTermsVersion; + + String identityProvider; } diff --git a/api/src/main/java/org/snomed/snap2snomed/model/DbMapView.java b/api/src/main/java/org/snomed/snap2snomed/model/DbMapView.java new file mode 100644 index 00000000..d42bbc76 --- /dev/null +++ b/api/src/main/java/org/snomed/snap2snomed/model/DbMapView.java @@ -0,0 +1,48 @@ +package org.snomed.snap2snomed.model; + +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.Immutable; +import org.snomed.snap2snomed.model.enumeration.MapStatus; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import java.io.Serializable; + +@Entity +@Immutable +@Table(name = "map_view") +public class DbMapView implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String mapRowId; + + @Column + private MapStatus status; + + @Column + private Long siblingRowAuthorTaskId; + + @Column + private Boolean blindMapFlag; + + @OneToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "mapRowId", insertable = false, updatable = false) + MapRow mapRow; + + @OneToOne + @JoinColumn(name = "siblingRowAuthorTaskId", insertable = false, updatable = false) + Task siblingRowAuthorTask; + +} diff --git a/api/src/main/java/org/snomed/snap2snomed/model/ImportedCodeSet.java b/api/src/main/java/org/snomed/snap2snomed/model/ImportedCodeSet.java index 2058a0d3..124599b4 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/ImportedCodeSet.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/ImportedCodeSet.java @@ -86,6 +86,12 @@ public class ImportedCodeSet implements Snap2SnomedEntity { @Size(min = 1, max = 30, message = "Version must be between 1 and 30 characters") String version; + @Size(min = 1, max = 255, message = "CodeSystem URI must be between 1 and 255 characters") + String systemUri; + + @Size(min = 1, max = 255, message = "ValueSet URI must be between 1 and 255 characters") + String valuesetUri; + @ReadOnlyProperty @ElementCollection @OrderColumn(name = "collection_order") diff --git a/api/src/main/java/org/snomed/snap2snomed/model/MapRow.java b/api/src/main/java/org/snomed/snap2snomed/model/MapRow.java index 452a92a5..3ad78706 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/MapRow.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/MapRow.java @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,8 @@ import static org.hibernate.envers.RelationTargetAuditMode.NOT_AUDITED; import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.io.Serializable; import java.time.Instant; import java.util.List; import java.util.SortedSet; @@ -36,9 +38,7 @@ import javax.persistence.OrderBy; import javax.persistence.PostLoad; import javax.persistence.PrePersist; -import javax.persistence.Table; import javax.persistence.Transient; -import javax.persistence.UniqueConstraint; import javax.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; @@ -48,6 +48,7 @@ import lombok.ToString.Exclude; import org.hibernate.envers.Audited; import org.snomed.snap2snomed.model.enumeration.MapStatus; +import org.snomed.snap2snomed.model.enumeration.NoteCategory; import org.snomed.snap2snomed.problem.mapping.InvalidStateTransitionProblem; import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; @@ -63,9 +64,11 @@ @NoArgsConstructor @Audited @EntityListeners(AuditingEntityListener.class) -@Table(name = "map_row", uniqueConstraints = { - @UniqueConstraint(name = "UniqueMapAndSourceCode", columnNames = {"map_id", "source_code_id"})}) -public class MapRow implements Snap2SnomedEntity { +//TODO get this working + unique +// @Table(name = "map_row", uniqueConstraints = { +// @UniqueConstraint(name = "UniqueIdAndMasterMapRowId", columnNames = {"map_id", "master_map_row_id"})}) +public class MapRow implements Snap2SnomedEntity, Serializable { + @Column(name = "created", nullable = false, updatable = false) @CreatedDate private Instant created; @@ -84,7 +87,7 @@ public class MapRow implements Snap2SnomedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + protected Long id; @NotNull @ManyToOne @@ -102,6 +105,8 @@ public class MapRow implements Snap2SnomedEntity { @JsonIgnore @Transient @Builder.Default + // NB this does not seem to be functional and I can't find a workaround + // https://github.com/spring-projects/spring-data-rest/issues/753?focusedCommentId=127082&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-127082 private boolean noMapPrevious = false; @NotNull @@ -110,6 +115,7 @@ public class MapRow implements Snap2SnomedEntity { @OneToMany(mappedBy = "mapRow", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @EqualsAndHashCode.Exclude + @Exclude @OrderBy("modified DESC, created DESC") private SortedSet<@NotNull Note> notes; @@ -119,6 +125,9 @@ public class MapRow implements Snap2SnomedEntity { @ManyToOne private Task reviewTask; + @ManyToOne + private Task reconcileTask; + @ManyToOne private User lastAuthor; @@ -129,6 +138,10 @@ public class MapRow implements Snap2SnomedEntity { @Exclude List mapRowTargets; + @Column(name = "blind_map_flag") + @NotNull + private Boolean blindMapFlag; + @Projection(name = "withLatestNote", types = {MapRow.class}) public interface MapRowWithLatestNote { @@ -151,7 +164,7 @@ public interface MapRowWithLatestNote { Task getReviewTask(); default Instant getLatestNote() { - Note note = getNotes().stream().filter(n -> !n.isDeleted()).findFirst().orElse(null); + Note note = getNotes().stream().filter(n -> !n.isDeleted() && n.getCategory() == NoteCategory.USER).findFirst().orElse(null); if (note != null) { return note.getModified(); } diff --git a/api/src/main/java/org/snomed/snap2snomed/model/MapRowTarget.java b/api/src/main/java/org/snomed/snap2snomed/model/MapRowTarget.java index c30994f3..e2bd6797 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/MapRowTarget.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/MapRowTarget.java @@ -17,7 +17,10 @@ package org.snomed.snap2snomed.model; import java.time.Instant; +import java.util.Collection; +import java.util.Set; import javax.persistence.Column; +import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.EntityListeners; import javax.persistence.GeneratedValue; @@ -25,6 +28,7 @@ import javax.persistence.Id; import javax.persistence.ManyToOne; import javax.persistence.Table; +import javax.persistence.Transient; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; @@ -35,6 +39,7 @@ import lombok.NoArgsConstructor; import org.hibernate.envers.Audited; import org.snomed.snap2snomed.model.enumeration.MappingRelationship; +import org.snomed.snap2snomed.model.enumeration.TaskType; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; @@ -51,7 +56,7 @@ @Audited @EntityListeners(AuditingEntityListener.class) @Table(name = "map_row_target") -public class MapRowTarget implements Snap2SnomedEntity { +public class MapRowTarget implements Snap2SnomedEntity, java.lang.Comparable { @Column(name = "created", nullable = false, updatable = false) @CreatedDate private Instant created; @@ -89,8 +94,20 @@ public class MapRowTarget implements Snap2SnomedEntity { @Builder.Default private MappingRelationship relationship = MappingRelationship.TARGET_INEXACT; + /** + * A set of string tag values that can be used for a wide variety of purposes. + */ + @ElementCollection + private Set tags; + boolean flagged; + @ManyToOne + private User lastAuthor; + + @Transient + private TaskType taskType; + @Projection(name = "targetView", types = {MapRowTarget.class}) public interface TargetView { @@ -114,5 +131,14 @@ public interface TargetView { boolean getFlagged(); + Set getTags(); + + User getLastAuthor(); + + } + + @Override + public int compareTo(MapRowTarget o) { + return targetCode.compareTo(o.getTargetCode()); } } diff --git a/api/src/main/java/org/snomed/snap2snomed/model/MapView.java b/api/src/main/java/org/snomed/snap2snomed/model/MapView.java index 1541d023..d8a9e2ad 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/MapView.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/MapView.java @@ -14,91 +14,217 @@ * limitations under the License. */ -package org.snomed.snap2snomed.model; + package org.snomed.snap2snomed.model; -import java.time.Instant; + import java.time.Instant; + import java.util.ArrayList; +import java.util.Iterator; import java.util.List; + + import java.util.Set; + import javax.validation.constraints.NotNull; + + import org.snomed.snap2snomed.model.enumeration.MapStatus; + import org.snomed.snap2snomed.model.enumeration.MappingRelationship; -import javax.validation.constraints.NotNull; - -import org.snomed.snap2snomed.model.enumeration.MapStatus; -import org.snomed.snap2snomed.model.enumeration.MappingRelationship; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class MapView { - + import lombok.Builder; + import lombok.Data; + import lombok.NoArgsConstructor; + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public class MapView { + + /** Constructor for single map mode */ public MapView(MapRow row, MapRowTarget target, Instant latestNote) { this.rowId = row.getId(); + this.sourceId = row.getSourceCode().getId(); this.sourceIndex = row.getSourceCode().getIndex(); this.sourceCode = row.getSourceCode().getCode(); this.sourceDisplay = row.getSourceCode().getDisplay(); this.noMap = row.isNoMap(); this.latestNote = latestNote; + + this.appendedNotes = ""; + Iterator i = row.getNotes().iterator(); + while (i.hasNext()) { + Note note = i.next(); + if (!note.isDeleted()) { + this.appendedNotes += note.getCreated() + " " + note.noteBy.getFullName() + " " + note.noteText + ";"; + } + } + this.status = row.getStatus(); this.lastAuthor = row.getLastAuthor(); this.lastReviewer = row.getLastReviewer(); if (row.getAuthorTask() != null) { - this.assignedAuthor = row.getAuthorTask().getAssignee(); + this.assignedAuthor = new ArrayList(); + this.assignedAuthor.add(row.getAuthorTask().getAssignee()); } if (row.getReviewTask() != null) { this.assignedReviewer = row.getReviewTask().getAssignee(); - } + } if (null != target) { this.targetId = target.getId(); this.targetCode = target.getTargetCode(); this.targetDisplay = target.getTargetDisplay(); this.relationship = target.getRelationship(); this.flagged = target.isFlagged(); + this.targetTags = target.getTags(); } if (row.getSourceCode().getAdditionalColumns().size() > 0) { this.additionalColumns = row.getSourceCode().getAdditionalColumns(); } - } - @NotNull - private Long rowId; - - @NotNull - private Long sourceIndex; + /** Constructor for dual map mode - view screen */ + public MapView(MapRow row, MapRowTarget target, Instant latestNote, MapStatus status, Task siblingRowAuthorTask) { - @NotNull - private String sourceCode; - - @NotNull - private String sourceDisplay; + this.rowId = row.getId(); + this.sourceId = row.getSourceCode().getId(); + this.sourceIndex = row.getSourceCode().getIndex(); + this.sourceCode = row.getSourceCode().getCode(); + this.sourceDisplay = row.getSourceCode().getDisplay(); - private Boolean noMap; + if (row.getBlindMapFlag()) { + this.noMap = false; + this.latestNote = null; + this.lastAuthor = null; + this.lastReviewer = null; + } else { + this.noMap = row.isNoMap(); + this.latestNote = latestNote; + this.lastAuthor = row.getLastAuthor(); + this.lastReviewer = row.getLastReviewer(); + } - private Long targetId; + this.appendedNotes = ""; + Iterator i = row.getNotes().iterator(); + while (i.hasNext()) { + Note note = i.next(); + if (!note.isDeleted()) { + this.appendedNotes += note.getCreated() + " " + note.noteBy.getFullName() + " " + note.noteText + ";"; + } + } - private String targetCode; + this.status = (status != null ? status : row.getStatus()); - private String targetDisplay; + if (row.getAuthorTask() != null) { - private MappingRelationship relationship; + this.assignedAuthor = new ArrayList(); + this.assignedAuthor.add(row.getAuthorTask().getAssignee()); + if (siblingRowAuthorTask != null) { + this.assignedAuthor.add(siblingRowAuthorTask.assignee); + } - private MapStatus status; + } + if (row.getReviewTask() != null) { + this.assignedReviewer = row.getReviewTask().getAssignee(); + } + if (row.getReconcileTask() != null) { + this.assignedReconciler = row.getReconcileTask().getAssignee(); + } + if (null != target && !row.getBlindMapFlag()) { + this.targetId = target.getId(); + this.targetCode = target.getTargetCode(); + this.targetDisplay = target.getTargetDisplay(); + this.relationship = target.getRelationship(); + this.flagged = target.isFlagged(); + this.targetTags = target.getTags(); + } + if (row.getSourceCode().getAdditionalColumns().size() > 0) { + this.additionalColumns = row.getSourceCode().getAdditionalColumns(); + } - private Instant latestNote; + } - private User assignedAuthor; + /** Constructor for dual map mode - task screen */ + public MapView(MapRow row, MapRowTarget target, Instant latestNote, MapStatus status) { - private User assignedReviewer; + this.rowId = row.getId(); + this.sourceId = row.getSourceCode().getId(); + this.sourceIndex = row.getSourceCode().getIndex(); + this.sourceCode = row.getSourceCode().getCode(); + this.sourceDisplay = row.getSourceCode().getDisplay(); - private User lastAuthor; + this.noMap = row.isNoMap(); + this.latestNote = latestNote; + this.lastAuthor = row.getLastAuthor(); + this.lastReviewer = row.getLastReviewer(); - private User lastReviewer; + this.status = row.getStatus(); - private boolean flagged; + if (row.getAuthorTask() != null) { + this.assignedAuthor = new ArrayList(); + this.assignedAuthor.add(row.getAuthorTask().getAssignee()); + } + if (row.getReviewTask() != null) { + this.assignedReviewer = row.getReviewTask().getAssignee(); + } + if (null != target) { + this.targetId = target.getId(); + this.targetCode = target.getTargetCode(); + this.targetDisplay = target.getTargetDisplay(); + this.relationship = target.getRelationship(); + this.flagged = target.isFlagged(); + this.targetTags = target.getTags(); + } + if (row.getSourceCode().getAdditionalColumns().size() > 0) { + this.additionalColumns = row.getSourceCode().getAdditionalColumns(); + } + } + + @NotNull + private Long rowId; + + @NotNull + private Long sourceId; + + @NotNull + private Long sourceIndex; + + @NotNull + private String sourceCode; + + @NotNull + private String sourceDisplay; + + private Boolean noMap; + + private Long targetId; + + private String targetCode; + + private String targetDisplay; + + private MappingRelationship relationship; + + private MapStatus status; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSXXX", timezone = "UTC") + private Instant latestNote; + + private String appendedNotes; + + private List assignedAuthor; + + private User assignedReviewer; + + private User assignedReconciler; + + private User lastAuthor; + + private User lastReviewer; + + private boolean flagged; + + private Set targetTags; + + private List additionalColumns; - private List additionalColumns; } diff --git a/api/src/main/java/org/snomed/snap2snomed/model/Note.java b/api/src/main/java/org/snomed/snap2snomed/model/Note.java index 1889ce3b..042f442a 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/Note.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/Note.java @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,13 +34,17 @@ import lombok.NoArgsConstructor; import org.apache.commons.lang3.builder.CompareToBuilder; import org.hibernate.envers.Audited; +import org.snomed.snap2snomed.model.enumeration.NoteCategory; import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.ReadOnlyProperty; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.rest.core.config.Projection; +import com.fasterxml.jackson.annotation.JsonFormat; + @Entity @Data @Builder @@ -54,6 +58,7 @@ public class Note implements Comparable, Snap2SnomedEntity { @CreatedDate private Instant created; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSXXX", timezone = "UTC") @Column(name = "modified") @LastModifiedDate private Instant modified; @@ -87,13 +92,17 @@ public class Note implements Comparable, Snap2SnomedEntity { @Builder.Default private boolean deleted = false; + @ReadOnlyProperty + @NotNull(message = "A note must have a category") + NoteCategory category; + @Override public int compareTo(Note o) { // NB other fields are included to prevent collisions in sorted sets return new CompareToBuilder().append(o.getModified(), this.getModified()) .append(o.getCreated(), this.getCreated()).append(o.getId(), this.getId()) .append(this.getMapRow(), o.getMapRow()).append(this.getNoteBy(), o.getNoteBy()) - .append(this.getNoteText(), o.getNoteText()) + .append(this.getNoteText(), o.getNoteText()).append(this.getCategory(), o.getCategory()) .toComparison(); } @@ -111,6 +120,8 @@ public interface NoteView { Instant getCreated(); Instant getModified(); + + NoteCategory getCategory(); } } diff --git a/api/src/main/java/org/snomed/snap2snomed/model/Project.java b/api/src/main/java/org/snomed/snap2snomed/model/Project.java index 85601181..d4eaf738 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/Project.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/Project.java @@ -88,6 +88,10 @@ public class Project implements Snap2SnomedEntity { @Size(max = 200, message = "Description must be less than 200 characters") private String description; + @Column(name = "dual_map_mode") + @NotNull + private Boolean dualMapMode; + @OneToMany(mappedBy = "project", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) @ToString.Exclude @EqualsAndHashCode.Exclude @@ -120,6 +124,8 @@ public interface ListView { Instant getModified(); + Boolean getDualMapMode(); + @Value("#{target.getMaps().size()}") long getMapCount(); diff --git a/api/src/main/java/org/snomed/snap2snomed/model/enumeration/MapStatus.java b/api/src/main/java/org/snomed/snap2snomed/model/enumeration/MapStatus.java index 3e0b14e1..b9baf866 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/enumeration/MapStatus.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/enumeration/MapStatus.java @@ -50,7 +50,8 @@ public Boolean isValidTransitionForRole(MapStatus mapStatus, Role role) { public Boolean isValidTransition(MapStatus mapStatus) { if ( mapStatus == UNMAPPED || mapStatus == DRAFT || mapStatus == INREVIEW || mapStatus == ACCEPTED - || mapStatus == REJECTED || mapStatus == MAPPED ) { + || mapStatus == REJECTED || mapStatus == MAPPED + || mapStatus == RECONCILE ) { return true; } return false; @@ -64,6 +65,8 @@ public Boolean isValidTransitionForRole(MapStatus mapStatus, Role role) { return isAuthorTransition(mapStatus); } else if (role == Role.REVIEWER) { return isReviewTransition(mapStatus); + } else if (role == Role.RECONCILER) { + return isReconcileTransition(mapStatus); } return false; } @@ -86,7 +89,7 @@ public Boolean isValidTransitionForRole(MapStatus mapStatus, Role role) { @Override public Boolean isValidTransition(MapStatus mapStatus) { if ( mapStatus == INREVIEW || mapStatus == REJECTED - || mapStatus == ACCEPTED ) { + || mapStatus == ACCEPTED || mapStatus == RECONCILE) { return true; } return false; @@ -101,7 +104,8 @@ public Boolean isValidTransitionForRole(MapStatus mapStatus, Role role) { public Boolean isValidTransition(MapStatus mapStatus) { if ( mapStatus == UNMAPPED || mapStatus == DRAFT || mapStatus == INREVIEW || mapStatus == ACCEPTED - || mapStatus == REJECTED || mapStatus == MAPPED ) { + || mapStatus == REJECTED || mapStatus == MAPPED + || mapStatus == RECONCILE) { return true; } return false; @@ -115,14 +119,30 @@ public Boolean isValidTransitionForRole(MapStatus mapStatus, Role role) { return isAuthorTransition(mapStatus); } else if (role == Role.REVIEWER) { return isReviewTransition(mapStatus); + } else if (role == Role.RECONCILER) { + return isReconcileTransition(mapStatus); } return false; } + }, + RECONCILE { + @Override + public Boolean isValidTransition(MapStatus mapStatus) { + if ( mapStatus == MAPPED || mapStatus == RECONCILE || mapStatus == UNMAPPED) { + return true; + } + return false; + } + @Override + public Boolean isValidTransitionForRole(MapStatus mapStatus, Role role) { + return (role == Role.RECONCILER) && isValidTransition(mapStatus); + } }; public static enum Role { AUTHOR("Author", "authoring"), - REVIEWER("Reviewer", "review"); + REVIEWER("Reviewer", "review"), + RECONCILER("Reconciler", "reconcile"); private String name; private String stateName; @@ -144,6 +164,7 @@ public String getStateName() { private static final List authorCompleteStatuses = List.of(MapStatus.MAPPED, MapStatus.INREVIEW, MapStatus.ACCEPTED); private static final List reviewCompleteStatuses = List.of(MapStatus.ACCEPTED, MapStatus.REJECTED); + private static final List reconcileCompleteStatuses = List.of(MapStatus.MAPPED); public boolean isAuthorState() { return this.equals(UNMAPPED) || this.equals(DRAFT) || this.equals(MAPPED); @@ -157,6 +178,14 @@ public Boolean isReviewTransition(MapStatus status) { return status.equals(ACCEPTED) || status.equals(REJECTED) || status.equals(INREVIEW); } + public Boolean isReconcileState() { + return this.equals(RECONCILE); + } + + public Boolean isReconcileTransition(MapStatus status) { + return status.equals(MAPPED); + } + public static List getCompletedAuthorStatuses() { return authorCompleteStatuses; } @@ -164,6 +193,10 @@ public static List getCompletedAuthorStatuses() { public static List getCompletedReviewStatuses() { return reviewCompleteStatuses; } + + public static List getCompletedReconcileStatuses() { + return reconcileCompleteStatuses; + } } diff --git a/api/src/main/resources/db/migration/h2/V3__envers.sql b/api/src/main/java/org/snomed/snap2snomed/model/enumeration/NoteCategory.java similarity index 75% rename from api/src/main/resources/db/migration/h2/V3__envers.sql rename to api/src/main/java/org/snomed/snap2snomed/model/enumeration/NoteCategory.java index fee63f92..91b80ec6 100644 --- a/api/src/main/resources/db/migration/h2/V3__envers.sql +++ b/api/src/main/java/org/snomed/snap2snomed/model/enumeration/NoteCategory.java @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2023 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +14,8 @@ * limitations under the License. */ -create table if not exists revinfo -( - rev integer generated by default as identity, - revtstmp bigint, - primary key (rev) -); \ No newline at end of file +package org.snomed.snap2snomed.model.enumeration; + +public enum NoteCategory { + USER, STATUS; +} diff --git a/api/src/main/java/org/snomed/snap2snomed/model/enumeration/TaskType.java b/api/src/main/java/org/snomed/snap2snomed/model/enumeration/TaskType.java index 3ab22269..4cc6ed9b 100644 --- a/api/src/main/java/org/snomed/snap2snomed/model/enumeration/TaskType.java +++ b/api/src/main/java/org/snomed/snap2snomed/model/enumeration/TaskType.java @@ -17,5 +17,5 @@ package org.snomed.snap2snomed.model.enumeration; public enum TaskType { - AUTHOR, REVIEW; + AUTHOR, REVIEW, RECONCILE; } diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/DbMapViewRepository.java b/api/src/main/java/org/snomed/snap2snomed/repository/DbMapViewRepository.java new file mode 100644 index 00000000..d9243539 --- /dev/null +++ b/api/src/main/java/org/snomed/snap2snomed/repository/DbMapViewRepository.java @@ -0,0 +1,12 @@ +package org.snomed.snap2snomed.repository; + +import org.snomed.snap2snomed.model.DbMapView; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Transactional +public interface DbMapViewRepository extends PagingAndSortingRepository { + +} \ No newline at end of file diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/MapRowRepository.java b/api/src/main/java/org/snomed/snap2snomed/repository/MapRowRepository.java index 8a0e579e..d8624346 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/MapRowRepository.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/MapRowRepository.java @@ -87,13 +87,51 @@ public interface MapRowRepository Iterable findAll(); /* Note that the subselect in these update queries looks strange, but any form of join in an - * update statement is not permitted outside a subselect in a where clause in JPQL */ + * update statement is not permitted outside a subselect in a where clause in JPQL + * "(select * from map_row) is a workaround for mysql issue that prevents subqueries on the table + * being updated */ + @Query(value = "update map_row mr set mr.author_task_id = :taskId, mr.modified_by = :userId, mr.modified = :date " + + " where mr.map_id = :mapId " + + " and mr.source_code_id in " + + " (select code.id from imported_code code " + + " where code.imported_codeset_id = :sourceId " + + " and code._index between :lowerEndpoint and :upperEndpoint) " + + " and mr.author_task_id is null " + + " and not exists (select mr2.id from (select * from map_row) mr2 " + + " where mr2.map_id = mr.map_id " + + " and mr2.source_code_id = mr.source_code_id " + + " and mr2.author_task_id is null " + + " and mr2.id < mr.id)", nativeQuery = true) + @Modifying + @RestResource(exported = false) + void setAuthorTaskBySourceCodeRangeDualMap(Long taskId, Long mapId, String userId, Instant date, Long lowerEndpoint, Long upperEndpoint, Long sourceId); + + /* Note that the subselect in these update queries looks strange, but any form of join in an + * update statement is not permitted outside a subselect in a where clause in JPQL + * "(select * from map_row) is a workaround for mysql issue that prevents subqueries on the table + * being updated */ + @Query(value = "update map_row mr set mr.author_task_id = :taskId, mr.modified_by = :userId, mr.modified = :date " + + " where mr.map_id = :mapId " + + " and mr.source_code_id in " + + " (select code.id from imported_code code " + + " where code.imported_codeset_id = :sourceId " + + " and code._index in :singleIndexes) " + + " and mr.author_task_id is null " + + " and not exists (select mr2.id from (select * from map_row) mr2 " + + " where mr2.map_id = mr.map_id " + + " and mr2.source_code_id = mr.source_code_id " + + " and mr2.author_task_id is null " + + " and mr2.id < mr.id)", nativeQuery = true) + @Modifying + @RestResource(exported = false) + void setAuthorTaskBySourceCodeDualMap(Long taskId, Long mapId, String userId, Instant date, Set singleIndexes, Long sourceId); + @Query("update MapRow mr set mr.authorTask = :task, mr.modifiedBy = :user, mr.modified = :date" - + " where mr.map.id = :#{#task.map.id} " - + " and mr.sourceCode.id in " - + " (select code.id from ImportedCode code " - + " where code.importedCodeSet.id = :#{#task.map.source.id} " - + " and code.index between :lowerEndpoint and :upperEndpoint)") + + " where mr.map.id = :#{#task.map.id} " + + " and mr.sourceCode.id in " + + " (select code.id from ImportedCode code " + + " where code.importedCodeSet.id = :#{#task.map.source.id} " + + " and code.index between :lowerEndpoint and :upperEndpoint)") @Modifying @RestResource(exported = false) void setAuthorTaskBySourceCodeRange(Task task, Long lowerEndpoint, Long upperEndpoint, Instant date, String user); @@ -128,6 +166,26 @@ public interface MapRowRepository @RestResource(exported = false) void setReviewTaskBySourceCode(Task task, Set singleIndexes, Instant date, String user); + @Query("update MapRow mr set mr.reconcileTask = :task, mr.modifiedBy = :user, mr.modified = :date " + + " where mr.map.id = :#{#task.map.id} " + + " and mr.sourceCode.id in " + + " (select code.id from ImportedCode code " + + " where code.importedCodeSet.id = :#{#task.map.source.id} " + + " and code.index between :lowerEndpoint and :upperEndpoint)") + @Modifying + @RestResource(exported = false) + void setReconcileTaskBySourceCodeRange(Task task, Long lowerEndpoint, Long upperEndpoint, Instant date, String user); + + @Query("update MapRow mr set mr.reconcileTask = :task, mr.modifiedBy = :user, mr.modified = :date " + + " where mr.map.id = :#{#task.map.id} " + + " and mr.sourceCode.id in " + + " (select code.id from ImportedCode code " + + " where code.importedCodeSet.id = :#{#task.map.source.id} " + + " and code.index in :singleIndexes)") + @Modifying + @RestResource(exported = false) + void setReconcileTaskBySourceCode(Task task, Set singleIndexes, Instant date, String user); + @Query("update MapRow mr set mr.authorTask = null, mr.modifiedBy = :user, mr.modified = :date where mr.authorTask = :task") @Modifying @RestResource(exported = false) @@ -138,15 +196,25 @@ public interface MapRowRepository @RestResource(exported = false) void setReviewTaskToNull(Task task, Instant date, String user); + @Query("update MapRow mr set mr.reconcileTask = null, mr.modifiedBy = :user, mr.modified = :date where mr.reconcileTask = :task") + @Modifying + @RestResource(exported = false) + void setReconcileTaskToNull(Task task, Instant date, String user); + @Query("select distinct mr.sourceCode.index from MapRow mr " - + "where mr.reviewTask = :task or mr.authorTask = :task order by mr.sourceCode.index asc") + + "where mr.reconcileTask = :task or mr.reviewTask = :task or mr.authorTask = :task order by mr.sourceCode.index asc") @RestResource(exported = false) List getSourceRowIndexesForTask(Task task); - @Query("select distinct mr.sourceCode.index from MapRow mr where (mr.authorTask = :task or mr.reviewTask = :task) and mr.status not in (:statuses)") + @Query("select distinct mr.sourceCode.index from MapRow mr where (mr.authorTask = :task or mr.reviewTask = :task or mr.reconcileTask = :task) and mr.status not in (:statuses)") @RestResource(exported = false) List getSourceRowIndexesForTaskNotInState(Task task, List statuses); + @Query("select mr from MapRow mr where mr.map.id = :mapId " + + " and mr.sourceCode.id = :sourceCodeId " + + " and mr.id != :mapRowId") + MapRow findDualMapSiblingRow(Long mapId, Long sourceCodeId, Long mapRowId); + @Query("select new org.snomed.snap2snomed.controller.dto.AutomapRowDto(mr.id, mr.sourceCode.display) " + "from MapRow mr " + "where mr.authorTask.id = :taskId " @@ -154,14 +222,19 @@ public interface MapRowRepository @RestResource(exported = false) List findUnmappedAuthorTaskRows(Long taskId); - @Query("select mr from MapRow mr where mr.authorTask.id = :taskId or mr.reviewTask.id = :taskId") + @Query("select mr from MapRow mr where mr.authorTask.id = :taskId or mr.reviewTask.id = :taskId or mr.reconcileTask.id = :taskId") @RestResource(exported = false) List findMapRowsByTaskId(Long taskId); - @Query(value = "insert into map_row (status, map_id, source_code_id, created, created_by) select 0, :mapId, ic.id, :date, :user from imported_code ic where ic.imported_codeset_id = :sourceCodeSetId order by ic._index", nativeQuery = true) + @Query(value = "insert into map_row (status, map_id, source_code_id, created, created_by, blind_map_flag) select 0, :mapId, ic.id, :date, :user, :blindMapFlag from imported_code ic where ic.imported_codeset_id = :sourceCodeSetId order by ic._index", nativeQuery = true) + @Modifying + @RestResource(exported = false) + int createMapRows(long mapId, long sourceCodeSetId, Instant date, String user, boolean blindMapFlag); + + @Query(value = "insert into map_row (status, map_id, source_code_id, created, created_by, blind_map_flag) select 0, :mapId, source_code_id, created, created_by, TRUE from map_row where map_id = :mapId ORDER BY source_code_id", nativeQuery = true) @Modifying @RestResource(exported = false) - int createMapRows(long mapId, long sourceCodeSetId, Instant date, String user); + int createMapRowsDualMap(long mapId); /** * INSERT into map_row (created, created_by, modified, modified_by, @@ -172,7 +245,7 @@ public interface MapRowRepository * WHERE m.map_id = :sourceMapId * */ - @Query(value = "insert into map_row (created, created_by, modified, modified_by, no_map, status, last_author_id, last_reviewer_id, map_id, source_code_id) select :dateTime, :user, :dateTime, :user, no_map, status, m.last_author_id, m.last_reviewer_id, :mapId, m.source_code_id from map_row m where m.map_id = :sourceMapId", nativeQuery = true) + @Query(value = "insert into map_row (created, created_by, modified, modified_by, no_map, status, last_author_id, last_reviewer_id, map_id, source_code_id, blind_map_flag) select :dateTime, :user, m.modified, :user, no_map, status, m.last_author_id, m.last_reviewer_id, :mapId, m.source_code_id, m.blind_map_flag from map_row m where m.map_id = :sourceMapId", nativeQuery = true) @Modifying @RestResource(exported = false) int copyMapRows(Long mapId, Long sourceMapId, String user, Instant dateTime); @@ -189,7 +262,7 @@ public interface MapRowRepository * AND cn.imported_codeset_id = :newCodeSetId * */ - @Query(value = "insert into map_row (created, created_by, modified, modified_by, no_map, status, last_author_id, last_reviewer_id, map_id, source_code_id) select :dateTime, :user, :dateTime, :user, m.no_map, status, m.last_author_id, m.last_reviewer_id, :mapId, cn.id from map_row m, imported_code cn, imported_code co where m.map_id = :sourceMapId AND m.source_code_id = co.id AND co.code = cn.code AND cn.imported_codeset_id = :newCodeSetId", nativeQuery = true) + @Query(value = "insert into map_row (created, created_by, modified, modified_by, no_map, status, last_author_id, last_reviewer_id, map_id, source_code_id) select :dateTime, :user, m.modified, :user, m.no_map, status, m.last_author_id, m.last_reviewer_id, :mapId, cn.id from map_row m, imported_code cn, imported_code co where m.map_id = :sourceMapId AND m.source_code_id = co.id AND co.code = cn.code AND cn.imported_codeset_id = :newCodeSetId", nativeQuery = true) @Modifying @RestResource(exported = false) int copyMapRowsForNewSource(Long mapId, Long sourceMapId, String user, Instant dateTime, Long newCodeSetId); @@ -208,6 +281,31 @@ public interface MapRowRepository @RestResource(exported = false) int createMapRowsForNewSource(Long mapId, String user, Instant dateTime, Long newCodeSetId); + @Query(value = "insert into map_row " + + "(created, created_by, status, map_id, source_code_id, blind_map_flag) " + + "(select :dateTime created, :user created_by, 0, :mapId, c.id, TRUE " + + "from imported_code c " + + "where c.imported_codeset_id = :newCodeSetId " + + "AND c.id NOT IN (SELECT m.source_code_id FROM map_row m WHERE m.map_id = :mapId)) " + + "union all" + + "(select :dateTime created, :user created_by, 0, :mapId, c.id, TRUE " + + "from imported_code c " + + "where c.imported_codeset_id = :newCodeSetId " + + "AND c.id NOT IN (SELECT m.source_code_id FROM map_row m WHERE m.map_id = :mapId)) ", nativeQuery = true) + @Modifying + @RestResource(exported = false) + int createMapRowsForNewSourceForDualMap(Long mapId, String user, Instant dateTime, Long newCodeSetId); + + @Query(value = "update map_row " + + "set blind_map_flag = TRUE, " + + "status = 0, " + + "no_map = FALSE " + + "where map_id = :mapId " + + "and ((blind_map_flag = true and status = 1) or (blind_map_flag = true and status = 2))", nativeQuery = true) + @Modifying + @RestResource(exported = false) + int resetMapRowResetRowsForNewMap(Long mapId); + @Override @RestResource(exported = false) Iterable saveAll(Iterable entities); diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/MapRowTargetRepository.java b/api/src/main/java/org/snomed/snap2snomed/repository/MapRowTargetRepository.java index a63ffe19..86f4a0c7 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/MapRowTargetRepository.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/MapRowTargetRepository.java @@ -18,17 +18,17 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.SimpleExpression; import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.core.types.dsl.StringPath; import java.time.Instant; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.function.Function; -import org.snomed.snap2snomed.model.ImportedCode; import org.snomed.snap2snomed.model.MapRow; import org.snomed.snap2snomed.model.MapRowTarget; -import org.snomed.snap2snomed.model.QImportedCode; import org.snomed.snap2snomed.model.QMapRowTarget; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -45,6 +45,7 @@ import org.springframework.data.rest.core.annotation.RepositoryRestResource; import org.springframework.data.rest.core.annotation.RestResource; import org.springframework.security.access.prepost.PostFilter; +import org.springframework.transaction.annotation.Transactional; // @ PreAuthorize("isValidUser()") @RepositoryRestResource @@ -52,6 +53,9 @@ public interface MapRowTargetRepository extends RevisionRepository, CrudRepository, QuerydslPredicateExecutor, QuerydslBinderCustomizer { + String TARGET_OUT_OF_SCOPE_TAG = "target-out-of-scope"; + String TARGET_NO_ACTIVE_SUGGESTIONS_TAG = "target-no-active-suggestions-tag"; + // --------------------------------- // Exported in REST interface // --------------------------------- @@ -113,6 +117,22 @@ public interface MapRowTargetRepository @RestResource(exported = false) int copyMapRowTargets(Long mapId, Long sourceMapId, String user, Instant dateTime); + @Query(value = "insert into map_row_target " + + "(created, created_by, modified, modified_by, flagged, relationship, target_code, target_display, row_id) " + + "select :dateTime created, :user created_by, :dateTime modified, :user modified_by, " + + "false, s.relationship, s.target_code, s.target_display, tr.id row_id from map_row_target s, " + + "map_row sr, map_row tr " + + "where (s.row_id = sr.id) " + + "and (sr.map_id = :sourceMapId) " + + "and (tr.map_id = :mapId) " + + "and (sr.source_code_id = tr.source_code_id) " + + "and (sr.blind_map_flag = FALSE) " + + "and (sr.last_author_id = tr.last_author_id) " + + "and (sr.modified = tr.modified)", nativeQuery = true) + @Modifying + @RestResource(exported = false) + int copyMapRowTargetsForDualMap(Long mapId, Long sourceMapId, String user, Instant dateTime); + @Query(value = "insert into map_row_target " + "(created, created_by, modified, modified_by, flagged, relationship, target_code, " + "target_display, row_id) " + @@ -125,6 +145,26 @@ public interface MapRowTargetRepository @RestResource(exported = false) int copyMapRowTargetsForNewSource(Long mapId, Long sourceMapId, String user, Instant dateTime); + @Query(value = "insert into map_row_target " + + "(created, created_by, modified, modified_by, flagged, relationship, target_code, " + + "target_display, row_id) " + + "select :dateTime created, :user created_by, :dateTime modified, :user modified_by, " + + "false, s.relationship, s.target_code, s.target_display, tr.id row_id " + + "from map_row_target s, " + + "map_row sr, map_row tr, imported_code sc, imported_code tc " + + "where (s.row_id = sr.id) " + + "and (sr.map_id = :sourceMapId) " + + "and (tr.map_id = :mapId) " + + "and (sr.source_code_id = sc.id) " + + "and (sc.code = tc.code) " + + "and (tr.source_code_id = tc.id)" + + "and (sr.blind_map_flag = FALSE) " + + "and (sr.last_author_id = tr.last_author_id) " + + "and (sr.modified = tr.modified)", nativeQuery = true) + @Modifying + @RestResource(exported = false) + int copyMapRowTargetsForNewSourceForDualMap(Long mapId, Long sourceMapId, String user, Instant dateTime); + @Override @RestResource(exported = false) @PostFilter("isAdminUser() || hasAnyProjectRoleForMapId(filterObject.row.map.id)") @@ -142,19 +182,28 @@ public interface MapRowTargetRepository @RestResource(exported = false) long count(); - @Query(value = "update map_row_target mt set mt.flagged = true, mt.modified = :dateTime, mt.modified_by = :user " + - " where mt.id in (:ids)", nativeQuery = true) + @Transactional @Modifying @RestResource(exported = false) - int flagMapTargets(List ids, String user, Instant dateTime); + @Query(value = "insert ignore into map_row_target_tags " + + "select id, '" + TARGET_OUT_OF_SCOPE_TAG + "' " + + "from map_row_target " + + "where id in :ids", + nativeQuery = true) + int addOutOfScopeTag(Collection ids); // Query DSL is used internally, don't want to expose it externally because we'd need to secure it // hence disable following methods @Override - default public void customize(QuerydslBindings bindings, QMapRowTarget root) { + default void customize(QuerydslBindings bindings, QMapRowTarget root) { bindings.bind(String.class).first((SingleValueBinding) StringExpression::containsIgnoreCase); - bindings.bind(root.row.sourceCode).first((SingleValueBinding) SimpleExpression::eq); + bindings.bind(root.row.sourceCode).first(SimpleExpression::eq); + bindings.bind(root.tags).first((path, value) -> value.stream() + .map(path::contains) + .reduce(BooleanExpression::and) + .orElseThrow()); + bindings.bind(root.row.map.id).as("mapId").first(SimpleExpression::eq); } @Override @@ -192,4 +241,5 @@ default public void customize(QuerydslBindings bindings, QMapRowTarget root) { @Override @RestResource(exported = false) R findBy(Predicate predicate, Function, R> queryFunction); + } diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/NoteRepository.java b/api/src/main/java/org/snomed/snap2snomed/repository/NoteRepository.java index 852f5ea1..9a3d6732 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/NoteRepository.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/NoteRepository.java @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,8 @@ import java.util.Optional; import org.snomed.snap2snomed.model.Note; +import org.snomed.snap2snomed.model.Task; +import org.snomed.snap2snomed.model.enumeration.NoteCategory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -39,6 +41,13 @@ public interface NoteRepository extends RevisionRepository, "?#{@authenticationFacadeImpl.principalSubject} and n.mapRow.id = :id and (u member of n.mapRow.map.project.owners or u member of n.mapRow.map.project.members or u member of n.mapRow.map.project.guests))) ") Page findByMapRowId(Long id, Pageable pageable); + @Query("select n from Note n where n.mapRow.id = :id and n.deleted = false and " + + "n.category = :category and " + + "(true = ?#{@authenticationFacadeImpl.isAdminUser()} or exists (select 1 from User u where u.id = " + + "?#{@authenticationFacadeImpl.principalSubject} and n.mapRow.id = :id " + + "and (u member of n.mapRow.map.project.owners or u member of n.mapRow.map.project.members or u member of n.mapRow.map.project.guests))) ") + Page findByMapRowIdAndCategory(Long id, NoteCategory category, Pageable pageable); + @Override @Query("select n from Note n where n.deleted = false and true = ?#{@authenticationFacadeImpl.isAdminUser()} or " + "exists (select 1 from User u where u.id = ?#{@authenticationFacadeImpl.principalSubject} and (u member of n.mapRow.map.project.owners or u member of n.mapRow.map.project.members or u member of n.mapRow.map.project.guests)) ") diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/TaskRepository.java b/api/src/main/java/org/snomed/snap2snomed/repository/TaskRepository.java index f0504da4..fd3f3fe5 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/TaskRepository.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/TaskRepository.java @@ -89,7 +89,7 @@ public interface TaskRepository @RestResource(exported = false) @Modifying - @Query("delete from Task t where not exists (select 1 from MapRow mr where mr.authorTask = t) and not exists (select 1 from MapRow mr where mr.reviewTask = t)") + @Query("delete from Task t where not exists (select 1 from MapRow mr where mr.authorTask = t) and not exists (select 1 from MapRow mr where mr.reviewTask = t) and not exists (select 1 from MapRow mr where mr.reconcileTask = t)") void deleteTasksWithNoMapRows(); @RestResource(exported = false) diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapEventHandler.java b/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapEventHandler.java index e74027c9..27b70839 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapEventHandler.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapEventHandler.java @@ -89,7 +89,15 @@ public void handleBeforeSave(Map map) { @HandleAfterCreate public void handleMapAfterCreate(Map map) { - mapRowRepository.createMapRows(map.getId(), map.getSource().getId(), Instant.now(), authenticationFacade.getPrincipalSubject()); + + if (map.getProject().getDualMapMode()) { + mapRowRepository.createMapRows(map.getId(), map.getSource().getId(), Instant.now(), authenticationFacade.getPrincipalSubject(), true); + mapRowRepository.createMapRowsDualMap(map.getId()); + } + else { + mapRowRepository.createMapRows(map.getId(), map.getSource().getId(), Instant.now(), authenticationFacade.getPrincipalSubject(), false); + } + } diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowEventHandler.java b/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowEventHandler.java index 5377f648..3fb57173 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowEventHandler.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowEventHandler.java @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,16 +16,23 @@ package org.snomed.snap2snomed.repository.handler; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.SortedSet; + import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; import lombok.extern.slf4j.Slf4j; import org.snomed.snap2snomed.model.*; import org.snomed.snap2snomed.model.enumeration.MapStatus; +import org.snomed.snap2snomed.model.enumeration.NoteCategory; import org.snomed.snap2snomed.problem.auth.NotAuthorisedProblem; import org.snomed.snap2snomed.problem.mapping.InvalidMappingProblem; import org.snomed.snap2snomed.problem.mapping.UnauthorisedMappingProblem; import org.snomed.snap2snomed.repository.MapRowRepository; import org.snomed.snap2snomed.repository.MapRowTargetRepository; +import org.snomed.snap2snomed.repository.NoteRepository; import org.snomed.snap2snomed.security.AuthenticationFacade; import org.snomed.snap2snomed.util.EntityUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -48,6 +55,9 @@ public class MapRowEventHandler { @Autowired MapRowTargetRepository mapRowTargetRepository; + @Autowired + NoteRepository noteRepository; + @Autowired AuthenticationFacade authenticationFacade; @@ -86,9 +96,95 @@ public void handleBeforeLinkDelete(MapRow mapRow) { * @param mapRow */ public void performAutomaticUpdates(MapRow mapRow) { + + MapStatus originalStatus = mapRow.getStatus(); + updateLastAuthorOrReviewed(mapRow); - if (mapRow.isNoMap() && !mapRow.isNoMapPrevious()) { + if (mapRow.getMap().getProject().getDualMapMode()) { + + MapRow siblingMapRow = mapRowRepository.findDualMapSiblingRow(mapRow.getMap().getId(), mapRow.getSourceCode().getId(), mapRow.getId()); + + if (null != siblingMapRow) { + + // 1. check if both dual map rows have been mapped + if (mapRow.getStatus() == MapStatus.MAPPED && siblingMapRow.getStatus() == MapStatus.MAPPED) { + + // 2. auto create note documenting those responsible for dual mapping + //TODO translate note .. maybe this has to be pushed to the ui + Note author1Note = createNote(mapRow.getAuthorTask().getAssignee().getFullName() + " dual mapped this", + mapRow.getModified(), mapRow.getAuthorTask().getAssignee(), mapRow, NoteCategory.STATUS, false); + Note author2Note = createNote(siblingMapRow.getAuthorTask().getAssignee().getFullName() + " dual mapped this", + siblingMapRow.getModified(), siblingMapRow.getAuthorTask().getAssignee(), siblingMapRow, NoteCategory.STATUS, false); + noteRepository.save(author1Note); + noteRepository.save(author2Note); + + // 3a. check if both dual map rows agree .. if so resolve into one map row + if (isEquivalentTarget(mapRow, siblingMapRow)) { + mapRow.setAuthorTask(null); + mapRow.setBlindMapFlag(Boolean.FALSE); + SortedSet siblingNotes = siblingMapRow.getNotes(); + for (Note note : siblingNotes) { + Note newNote = createNote(note.getNoteText(), note.getCreated(), note.getNoteBy(), mapRow, note.getCategory(), note.isDeleted()); + noteRepository.save(newNote); + } + mapRowRepository.deleteById(siblingMapRow.getId()); + mapRowRepository.save(mapRow); + siblingMapRow = null; + } + else { + // 3b. dual map rows don't agree .. put the dual map rows into the reconcile state + mapRow.setStatus(MapStatus.RECONCILE); + mapRow.setAuthorTask(null); + mapRow.setBlindMapFlag(Boolean.FALSE); + siblingMapRow.setStatus(MapStatus.RECONCILE); + siblingMapRow.setAuthorTask(null); + siblingMapRow.setBlindMapFlag(Boolean.FALSE); + mapRowRepository.save(mapRow); + mapRowRepository.save(siblingMapRow); + } + + // TODO check if author task no longer has any tasks and delete the task - ML said framework should take care of this + } + else if ((mapRow.getStatus() == MapStatus.MAPPED && siblingMapRow.getStatus() == MapStatus.RECONCILE) || + (mapRow.getStatus() == MapStatus.RECONCILE && siblingMapRow.getStatus() == MapStatus.MAPPED)) { + + // Reconciler has fixed the dual map .. merge two rows together + + if (siblingMapRow.isNoMap()) { + mapRow.setNoMap(true); + } + + for (MapRowTarget siblingMapRowTarget : siblingMapRow.getMapRowTargets()) { + MapRowTarget newMapRowTarget = new MapRowTarget(siblingMapRowTarget.getCreated(), siblingMapRowTarget.getModified(), siblingMapRowTarget.getCreatedBy(), + siblingMapRowTarget.getModifiedBy(), null, mapRow, siblingMapRowTarget.getTargetCode(), + siblingMapRowTarget.getTargetDisplay(), siblingMapRowTarget.getRelationship(), siblingMapRowTarget.getTags(), siblingMapRowTarget.isFlagged(), siblingMapRowTarget.getLastAuthor(), null); + mapRowTargetRepository.save(newMapRowTarget); + } + + SortedSet siblingNotes = siblingMapRow.getNotes(); + for (Note note : siblingNotes) { + Note newNote = createNote(note.getNoteText(), note.getCreated(), note.getNoteBy(), mapRow, note.getCategory(), note.isDeleted()); + noteRepository.save(newNote); + } + + mapRowRepository.save(mapRow); + mapRowRepository.delete(siblingMapRow); + + } + else if (originalStatus == MapStatus.RECONCILE && mapRow.getStatus() == MapStatus.RECONCILE && siblingMapRow.getStatus() == MapStatus.RECONCILE && mapRow.isNoMap()) { + // Remove any targets the sibling row may have if we are in reconcile mode and noMap is + // NB had to put the logic here rather than in the if below as we get runtime error messages saying the mapRowTarget cannot be found + // even though it is there .. this made me move this functionality to the post save handler, however, I've moved it back here as I need + // to know if we are in a reconcile TASK .. not just in an author task with the row transitioning to RECONCILE when both rows are put into + // MAPPED state + mapRowTargetRepository.deleteAllByRow(siblingMapRow); + } + } + } + + // NB this if logic is not functional, but it seems to work as it would only remove targets if noMap is true and they exist + if (mapRow.isNoMap() && !mapRow.isNoMapPrevious()) { log.debug("noMap has been change to true, removing dangling MapRowTargets"); // Clean up MapRowTargets if and only if noMap is true and was previously false mapRowTargetRepository.deleteAllByRow(mapRow); @@ -96,25 +192,85 @@ public void performAutomaticUpdates(MapRow mapRow) { mapRow.setNoMapPrevious(mapRow.isNoMap()); } + private Note createNote(String noteText, Instant createdInstant, User createdByUser, MapRow mapRow, NoteCategory noteCategory, Boolean deleted) { + Note note = new Note(); + note.setNoteText(noteText); + note.setCreated(createdInstant); + note.setCreatedBy(createdByUser.getId()); + note.setNoteBy(createdByUser); + note.setMapRow(mapRow); + note.setCategory(noteCategory); + note.setDeleted(deleted); + return note; + } + + /* + * A target is considered equal if the targetCode, relationship, noMap, and status match + */ + private boolean isEquivalentTarget(MapRow mapRow1, MapRow mapRow2) { + + if (mapRow1.isNoMap() != mapRow2.isNoMap()) { + return false; + } + else if (!mapRow1.getStatus().equals(mapRow2.getStatus())) { + return false; + } + else { + List mapRowTargets1 = mapRow1.getMapRowTargets(); + List mapRowTargets2 = mapRow2.getMapRowTargets(); + if (mapRowTargets1.size() != mapRowTargets2.size()) { + return false; + } + else { + if (mapRowTargets1.size() > 1) { // size will be identical here, only test one + Collections.sort(mapRowTargets1); + Collections.sort(mapRowTargets2); + } + + for (int i = 0; i < mapRowTargets1.size(); i++) { + if (!mapRowTargets1.get(i).getTargetCode().equals(mapRowTargets2.get(i).getTargetCode())) { + return false; + } + else if (!mapRowTargets1.get(i).getRelationship().equals(mapRowTargets2.get(i).getRelationship())) { + return false; + } + } + + } + } + + return true; + } + private void validateRequestedChange(MapRow mapRow, User currentUser, MapRow mapRowFromDatabase) { + //todo consider for sibling row .. should be invoked on a change to nomap? if (immutableFieldChanged(mapRow, mapRowFromDatabase)) { throw new UnauthorisedMappingProblem("Attempt to change an immutable field"); } boolean author = EntityUtils.isTaskAssignee(currentUser, mapRowFromDatabase.getAuthorTask()); boolean reviewer = EntityUtils.isTaskAssignee(currentUser, mapRowFromDatabase.getReviewTask()); - MapStatus.Role role = author ? MapStatus.Role.AUTHOR : reviewer ? MapStatus.Role.REVIEWER : null ; + boolean reconciler = EntityUtils.isTaskAssignee(currentUser, mapRowFromDatabase.getReconcileTask()); + MapStatus.Role authorOrReviewerRole = author ? MapStatus.Role.AUTHOR : reviewer ? MapStatus.Role.REVIEWER : null; + MapStatus.Role reconcilerRole = reconciler ? MapStatus.Role.RECONCILER : null; if (author && reviewer) { // state transition will be validated precommit validateAuthorChanges(mapRow, mapRowFromDatabase); - } else if (author || reviewer) { - if (!mapRowFromDatabase.getStatus().isValidTransitionForRole(mapRow.getStatus(), role)) { - throw new UnauthorisedMappingProblem( - role.getName() + " can only change rows that are in a valid " + role.getStateName() - + " state, this row's state is " + mapRowFromDatabase.getStatus()); + } else if (author || reviewer || reconciler) { + if (!mapRowFromDatabase.getStatus().isValidTransitionForRole(mapRow.getStatus(), authorOrReviewerRole)) { + if (null == reconcilerRole) { + throw new UnauthorisedMappingProblem( + authorOrReviewerRole.getName() + " can only change rows that are in a valid " + authorOrReviewerRole.getStateName() + + " state, this row's state is " + mapRowFromDatabase.getStatus()); + } + else if (!mapRowFromDatabase.getStatus().isValidTransitionForRole(mapRow.getStatus(), reconcilerRole)) { + throw new UnauthorisedMappingProblem( + reconcilerRole.getName() + " can only change rows that are in a valid " + reconcilerRole.getStateName() + + " state, this row's state is " + mapRowFromDatabase.getStatus()); + } } - if (author) { + if (author || reconciler) { validateAuthorChanges(mapRow, mapRowFromDatabase); } else if (reviewer) { if (mapRowFromDatabase.isNoMap() != mapRow.isNoMap()) { @@ -122,7 +278,7 @@ private void validateRequestedChange(MapRow mapRow, User currentUser, MapRow map } } } else { - throw new UnauthorisedMappingProblem("User is neither author nor reviewer for the requested update"); + throw new UnauthorisedMappingProblem("User is not author / reviewer / reconciler for the requested update"); } } @@ -130,13 +286,25 @@ private void validateAuthorChanges(MapRow mapRow, MapRow mapRowFromDatabase) { if (mapRowFromDatabase.getStatus().equals(MapStatus.REJECTED) && mapRow.getStatus().equals(MapStatus.REJECTED) && (mapRow.isNoMap() != mapRowFromDatabase.isNoMap())) { throw new UnauthorisedMappingProblem("Author cannot change mapping in the REJECTED state, change to DRAFT first"); - } else if (!MapStatus.UNMAPPED.equals(mapRow.getStatus()) && !mapRow.isNoMap() && mapRow.getMapRowTargets().isEmpty()) { - throw new InvalidMappingProblem("Cannot change state from UNMAPPED for row with no mappings and 'no map' not set"); + } else if (!MapStatus.UNMAPPED.equals(mapRow.getStatus()) && !MapStatus.RECONCILE.equals(mapRow.getStatus()) && !mapRow.isNoMap() && mapRow.getMapRowTargets().isEmpty()) { + if (mapRow.getMap().getProject().getDualMapMode()) { + // target or no map may be on the sibling row + if (MapStatus.MAPPED.equals(mapRow.getStatus())) { + MapRow siblingMapRow = mapRowRepository.findDualMapSiblingRow(mapRow.getMap().getId(), mapRow.getSourceCode().getId(), mapRow.getId()); + if (siblingMapRow == null || (siblingMapRow.isNoMap() && siblingMapRow.getMapRowTargets().isEmpty())) { + throw new InvalidMappingProblem("Cannot change state to MAPPED for dual mapped rows with no mappings and 'no map' not set"); + } + } + } + else { + throw new InvalidMappingProblem("Cannot change state from UNMAPPED for row with no mappings and 'no map' not set"); + } } else if (MapStatus.UNMAPPED.equals(mapRow.getStatus()) && (mapRow.isNoMap() || !mapRow.getMapRowTargets().isEmpty())) { throw new InvalidMappingProblem("Cannot change state to UNMAPPED for row with mapping targets or 'no map' set"); } } + private boolean immutableFieldChanged(MapRow mapRow, MapRow mapRowFromDatabase) { return !EntityUtils.areEqual(mapRowFromDatabase.getMap(), mapRow.getMap()) || !EntityUtils.areEqual(mapRowFromDatabase.getAuthorTask(), mapRow.getAuthorTask()) @@ -150,6 +318,7 @@ public void handleMapRowAfterSave(MapRow mapRow) { && (mapRow.isNoMap() || mapRowTargetRepository.exists(QMapRowTarget.mapRowTarget.row.eq(mapRow)))) { mapRow.setStatus(MapStatus.DRAFT); } + } @HandleBeforeLinkSave @@ -159,6 +328,7 @@ public void handleMapRowTaskLinkBeforeChange(MapRow mapRow, Task task) { } private void updateLastAuthorOrReviewed(MapRow mapRow) { + //TODO review for reconcile if (mapRow.getStatus().isAuthorState()) { mapRow.setLastAuthor(authenticationFacade.getAuthenticatedUser()); } else { diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowTargetEventHandler.java b/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowTargetEventHandler.java index 46e29843..2b0ede02 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowTargetEventHandler.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/handler/MapRowTargetEventHandler.java @@ -25,11 +25,11 @@ import org.snomed.snap2snomed.model.Map; import org.snomed.snap2snomed.model.MapRow; import org.snomed.snap2snomed.model.MapRowTarget; -import org.snomed.snap2snomed.model.Note; import org.snomed.snap2snomed.model.Project; import org.snomed.snap2snomed.model.QMapRowTarget; import org.snomed.snap2snomed.model.User; import org.snomed.snap2snomed.model.enumeration.MapStatus; +import org.snomed.snap2snomed.model.enumeration.TaskType; import org.snomed.snap2snomed.problem.auth.NotAuthorisedProblem; import org.snomed.snap2snomed.problem.mapping.UnauthorisedMappingProblem; import org.snomed.snap2snomed.repository.MapRowRepository; @@ -105,18 +105,20 @@ private boolean isOwner(MapRowTarget mapRowTarget) { * @param mapRowTarget */ public void performAutomaticUpdates(MapRowTarget mapRowTarget) { - MapRowTarget storedMapRowTarget = null; - if (mapRowTarget.getId() != null) { - em.detach(mapRowTarget); - storedMapRowTarget = mapRowTargetRepository.findById(mapRowTarget.getId()).orElse(null); - em.merge(mapRowTarget); - } - if (storedMapRowTarget == null || - !storedMapRowTarget.getRelationship().equals(mapRowTarget.getRelationship()) - || !storedMapRowTarget.getTargetCode().equals(mapRowTarget.getTargetCode()) - || !storedMapRowTarget.getTargetDisplay().equals(mapRowTarget.getTargetDisplay())) { - // only revert to DRAFT state for a change other than being flagged - mapRowTarget.getRow().setStatus(MapStatus.DRAFT); + if (mapRowTarget.getRow().getStatus() != MapStatus.RECONCILE) { + MapRowTarget storedMapRowTarget = null; + if (mapRowTarget.getId() != null) { + em.detach(mapRowTarget); + storedMapRowTarget = mapRowTargetRepository.findById(mapRowTarget.getId()).orElse(null); + em.merge(mapRowTarget); + } + if (storedMapRowTarget == null || + !storedMapRowTarget.getRelationship().equals(mapRowTarget.getRelationship()) + || !storedMapRowTarget.getTargetCode().equals(mapRowTarget.getTargetCode()) + || !storedMapRowTarget.getTargetDisplay().equals(mapRowTarget.getTargetDisplay())) { + // only revert to DRAFT state for a change other than being flagged + mapRowTarget.getRow().setStatus(MapStatus.DRAFT); + } } updateLastAuthorOrReviewed(mapRowTarget, false); } @@ -154,16 +156,17 @@ private void validateUserRole(MapRowTarget mapRowTarget, User currentUser) { MapRow mapRow = mapRowRepository.findById(mapRowTarget.getRow().getId()).orElseThrow(); boolean author = EntityUtils.isTaskAssignee(currentUser, mapRow.getAuthorTask()); boolean reviewer = EntityUtils.isTaskAssignee(currentUser, mapRow.getReviewTask()); + boolean reconciler = EntityUtils.isTaskAssignee(currentUser, mapRow.getReconcileTask()); - if (!author && reviewer && (mapRow.getStatus().isAuthorState() || !isFlagOnlyChange(mapRowTarget))) { + if (!author && reviewer && mapRowTarget.getTaskType() == TaskType.REVIEW &&(mapRow.getStatus().isAuthorState() || !isFlagOnlyChange(mapRowTarget))) { throw new UnauthorisedMappingProblem( "A reviewer may only change the flagged attribute of target in a review state, state is " + mapRow.getStatus()); - } else if (!reviewer && author && !(mapRow.getStatus().isAuthorState() || mapRow.getStatus().equals(MapStatus.REJECTED))) { + } else if (!reviewer && author && !(mapRow.getStatus().isAuthorState() || mapRow.getStatus().equals(MapStatus.REJECTED) || mapRow.getStatus().isReconcileState())) { throw new UnauthorisedMappingProblem( "An author may only change targets for rows in an author state, state is " + mapRow.getStatus()); - } else if (!author && !reviewer) { + } else if (!author && !reviewer && !reconciler) { throw new UnauthorisedMappingProblem( - "User must be a reviewer or an author to create or modify a map row target"); + "User must be a reviewer or an author or a reconciler to create or modify a map row target"); } } @@ -192,7 +195,10 @@ private void updateMapRowStatusAfterTargetRemoved(MapRowTarget mapRowTarget) { private void updateLastAuthorOrReviewed(MapRowTarget mapRowTarget, boolean isDelete) { MapRow mapRow = mapRowTarget.getRow(); - if (isDelete || mapRow.getStatus().isAuthorState()) { + if (isDelete || mapRow.getStatus().isAuthorState() || mapRow.getStatus().isReconcileState()) { + if (!isDelete) { + mapRowTarget.setLastAuthor(authenticationFacade.getAuthenticatedUser()); + } mapRow.setLastAuthor(authenticationFacade.getAuthenticatedUser()); } else { mapRow.setLastReviewer(authenticationFacade.getAuthenticatedUser()); diff --git a/api/src/main/java/org/snomed/snap2snomed/repository/handler/TaskEventHandler.java b/api/src/main/java/org/snomed/snap2snomed/repository/handler/TaskEventHandler.java index ba1af0ce..5c8795c8 100644 --- a/api/src/main/java/org/snomed/snap2snomed/repository/handler/TaskEventHandler.java +++ b/api/src/main/java/org/snomed/snap2snomed/repository/handler/TaskEventHandler.java @@ -116,8 +116,10 @@ public void handleTaskAfterUpdate(Task task) { Instant modified = Instant.now(); if (task.getType().equals(TaskType.AUTHOR)) { mapRowRepository.setAuthorTaskToNull(task, modified, principalSubject); - } else { + } else if (task.getType().equals(TaskType.REVIEW)) { mapRowRepository.setReviewTaskToNull(task, modified, principalSubject); + } else if (task.getType().equals(TaskType.RECONCILE)) { + mapRowRepository.setReconcileTaskToNull(task, modified, principalSubject); } setMapRows(task); } @@ -139,8 +141,10 @@ public void handleBeforeDelete(Task task) { if (task.getType().equals(TaskType.AUTHOR)) { mapRowRepository.setAuthorTaskToNull(task, modified, principalSubject); - } else { + } else if (task.getType().equals(TaskType.REVIEW)) { mapRowRepository.setReviewTaskToNull(task, modified, principalSubject); + } else if (task.getType().equals(TaskType.RECONCILE)) { + mapRowRepository.setReconcileTaskToNull(task, modified, principalSubject); } } @@ -234,6 +238,11 @@ private void validateSaveAssignee(Task task) { indexesWithRoleConflict.addAll( findIndexesIncompatiblyAssigned(task, mapRow.authorTask.assignee.id.eq(assigneeId).or(mapRow.lastAuthor.id.eq(assigneeId)))); + } else if (task.getType().equals(TaskType.RECONCILE)) { + // TODO: any restrictions? + // indexesWithRoleConflict.addAll( + // findIndexesIncompatiblyAssigned(task, + // mapRow.authorTask.assignee.id.eq(assigneeId).or(mapRow.lastAuthor.id.eq(assigneeId)))); } } @@ -249,6 +258,10 @@ private void validateSaveAssignee(Task task) { // cannot assign rows already assigned to a review task indexesWithExistingTask.addAll( findIndexesIncompatiblyAssigned(task, mapRow.reviewTask.isNotNull())); + } else if (task.getType().equals(TaskType.RECONCILE)) { + // cannot assign rows already assigned to a reconcile task + indexesWithExistingTask.addAll( + findIndexesIncompatiblyAssigned(task, mapRow.reconcileTask.isNotNull())); } } @@ -268,16 +281,31 @@ private List findIndexesIncompatiblyAssigned(Task task, BooleanExpression .and(mapRow.reviewTask.id.ne(task.getId())); } whereClause = getSourceIndexWhereClause(task, whereClause).and(expression); + if (task.getType() == TaskType.AUTHOR && task.getMap().getProject().getDualMapMode()) { + return new JPAQuery(entityManager) + .select(mapRow.sourceCode.index) + .from(mapRow) + .leftJoin(mapRow.lastAuthor) + .leftJoin(mapRow.lastReviewer) + .leftJoin(mapRow.authorTask) + .leftJoin(mapRow.reviewTask) + .where(whereClause) + .groupBy(mapRow.sourceCode.index) + .having(mapRow.sourceCode.index.count().gt(Integer.valueOf(1))) + .fetch(); + } + else { + return new JPAQuery(entityManager) + .select(mapRow.sourceCode.index).distinct() + .from(mapRow) + .leftJoin(mapRow.lastAuthor) + .leftJoin(mapRow.lastReviewer) + .leftJoin(mapRow.authorTask) + .leftJoin(mapRow.reviewTask) + .where(whereClause) + .fetch(); + } - return new JPAQuery(entityManager) - .select(mapRow.sourceCode.index).distinct() - .from(mapRow) - .leftJoin(mapRow.lastAuthor) - .leftJoin(mapRow.lastReviewer) - .leftJoin(mapRow.authorTask) - .leftJoin(mapRow.reviewTask) - .where(whereClause) - .fetch(); } private BooleanExpression getSourceIndexWhereClause(Task task, BooleanExpression whereClause) { @@ -330,6 +358,7 @@ private void setMapRows(Task task) { * By reassigning rows to different tasks, it is possible for a task to become "empty" i.e. have no rows associated with it. This case * will be handled by deleting these tasks. */ + taskRepository.deleteTasksWithNoMapRows(); } @@ -340,14 +369,29 @@ private void associateMapRows(Task task, RangeSet rangeSet) { Instant modified = Instant.now(); String user = authenticationFacade.getPrincipalSubject(); if (task.getType().equals(TaskType.AUTHOR)) { - addRange = (lower, upper) -> + if (task.getMap().getProject().getDualMapMode()) { + // dual mapping mode + addRange = (lower, upper) -> mapRowRepository.setAuthorTaskBySourceCodeRangeDualMap(task.getId(), task.getMap().getId(), user, modified, lower, upper, + task.getMap().getSource().getId()); + addCollection = (ids) -> mapRowRepository.setAuthorTaskBySourceCodeDualMap(task.getId(), task.getMap().getId(), user, modified, ids, + task.getMap().getSource().getId()); + } + else { + // single mapping mode + addRange = (lower, upper) -> mapRowRepository.setAuthorTaskBySourceCodeRange(task, lower, upper, modified, user); - addCollection = (ids) -> mapRowRepository.setAuthorTaskBySourceCode(task, ids, modified, user); + addCollection = (ids) -> mapRowRepository.setAuthorTaskBySourceCode(task, ids, modified, user); + } } else if (task.getType().equals(TaskType.REVIEW)) { addRange = (lower, upper) -> mapRowRepository.setReviewTaskBySourceCodeRange(task, lower, upper, modified, user); addCollection = (ids) -> mapRowRepository.setReviewTaskBySourceCode(task, ids, modified, user); + } else if (task.getType().equals(TaskType.RECONCILE)) { + addRange = (lower, upper) -> + mapRowRepository.setReconcileTaskBySourceCodeRange(task, lower, upper, modified, user); + addCollection = (ids) -> + mapRowRepository.setReconcileTaskBySourceCode(task, ids, modified, user); } else { throw Problem.builder().withTitle("Unknown task type " + task.getType()).withStatus( Status.INTERNAL_SERVER_ERROR).build(); diff --git a/api/src/main/java/org/snomed/snap2snomed/security/PreAuthFilter.java b/api/src/main/java/org/snomed/snap2snomed/security/PreAuthFilter.java index 77525b34..816ccf16 100644 --- a/api/src/main/java/org/snomed/snap2snomed/security/PreAuthFilter.java +++ b/api/src/main/java/org/snomed/snap2snomed/security/PreAuthFilter.java @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,6 +77,7 @@ public PreAuthFilter() { new FilterRule(HttpMethod.GET, "/users/.*", groupValues -> webSecurity.isValidUser()), new FilterRule(HttpMethod.GET, "/notes/search/findByMapRowId", groupValues -> webSecurity.isValidUser()), + new FilterRule(HttpMethod.GET, "/notes/search/findByMapRowIdAndCategory", groupValues -> webSecurity.isValidUser()), new FilterRule(HttpMethod.GET, "/notes/.*", groupValues -> webSecurity.isValidUser()), new FilterRule(HttpMethod.GET, "/notes", groupValues -> webSecurity.isValidUser()), @@ -85,6 +86,7 @@ public PreAuthFilter() { new FilterRule(HttpMethod.GET, "/map/([^\\/]+)/validateTargetCodes", groupValues -> webSecurity.isProjectOwnerForMapId(asLong(groupValues[0])) || webSecurity.isAdminUser()), new FilterRule(HttpMethod.GET, "/mapView", groupValues -> webSecurity.isValidUser()), new FilterRule(HttpMethod.GET, "/mapView/([^\\/]+)", groupValues -> webSecurity.hasAnyProjectRoleForMapId(asLong(groupValues[0])) || webSecurity.isAdminUser()), + new FilterRule(HttpMethod.GET, "/mapView/([^\\/]+)/\\$dualMapSiblingRow", groupValues -> webSecurity.hasAnyProjectRoleForMapId(asLong(groupValues[0])) || webSecurity.isAdminUser()), new FilterRule(HttpMethod.GET, "/mapView/task/([^\\/]+)", groupValues -> webSecurity.isTaskAssignee(asLong(groupValues[0])) || webSecurity.isAdminUser()), new FilterRule(HttpMethod.GET, "/task/([^\\/]+)/\\$countIncompleteRows", groupValues -> webSecurity.isValidUser()), new FilterRule(HttpMethod.GET, "/task/([^\\/]+)/.*", groupValues -> webSecurity.isTaskAssignee(asLong(groupValues[0])) || webSecurity.isAdminUser()), diff --git a/api/src/main/java/org/snomed/snap2snomed/service/FhirService.java b/api/src/main/java/org/snomed/snap2snomed/service/FhirService.java index 51eb9707..05453cf9 100644 --- a/api/src/main/java/org/snomed/snap2snomed/service/FhirService.java +++ b/api/src/main/java/org/snomed/snap2snomed/service/FhirService.java @@ -16,30 +16,37 @@ package org.snomed.snap2snomed.service; -import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; -import com.google.common.collect.Lists; -import lombok.extern.slf4j.Slf4j; -import org.hl7.fhir.r4.model.*; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.hl7.fhir.r4.model.BooleanType; +import org.hl7.fhir.r4.model.CanonicalType; +import org.hl7.fhir.r4.model.CodeType; +import org.hl7.fhir.r4.model.IntegerType; +import org.hl7.fhir.r4.model.ValueSet; import org.ihtsdo.snomed.util.SnomedUtils; import org.ihtsdo.snomed.util.rf2.schema.RF2SchemaConstants; import org.snomed.snap2snomed.config.Snap2snomedConfiguration; -import org.snomed.snap2snomed.config.TerminologyServerConfiguration; import org.snomed.snap2snomed.controller.dto.ValidationResult; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.crypto.codec.Utf8; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.Charset; -import java.util.*; -import java.util.stream.Collectors; +import com.google.common.collect.Lists; + +import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; +import lombok.extern.slf4j.Slf4j; @Component @Slf4j public class FhirService { - private static final String DEFAULT_CODE_SYSTEM = "http://snomed.info/sct"; + public static final String DEFAULT_CODE_SYSTEM = "http://snomed.info/sct"; @Autowired TerminologyProvider terminologyProvider; @@ -49,9 +56,9 @@ public class FhirService { public ValidationResult validateValueSetComposition( Set codes, String codeSystemVersion, String scope) throws IOException { - Set invalid = new HashSet<>(); + final Set invalid = new HashSet<>(); if (codes != null) { - for (String code : codes) { + for (final String code : codes) { if (code != null && !code.trim().isEmpty() && !isValidSctId(code, RF2SchemaConstants.PartionIdentifier.CONCEPT)) { invalid.add(code); } @@ -61,11 +68,11 @@ public ValidationResult validateValueSetComposition( if (codes == null || codes.isEmpty()) { return new ValidationResult(0, new HashSet<>(), new HashSet<>(), invalid); } - ValueSet valueSetToExpand = new ValueSet(); - List conceptSetComponents = new ArrayList<>(); - List conceptReferenceComponents = + final ValueSet valueSetToExpand = new ValueSet(); + final List conceptSetComponents = new ArrayList<>(); + final List conceptReferenceComponents = codes.stream().map(targetCode -> new ValueSet.ConceptReferenceComponent(new CodeType(targetCode))).collect(Collectors.toList()); - List> partitionedConceptReferenceComponents = + final List> partitionedConceptReferenceComponents = Lists.partition(conceptReferenceComponents, configuration.getDefaultTerminologyServer().getExpandBatchSize().intValue()); String reqScope = scope; if (reqScope != null) { @@ -73,9 +80,9 @@ public ValidationResult validateValueSetComposition( reqScope = codeSystemVersion + "?fhir_vs=ecl/" + reqScope; } reqScope = reqScope.replaceAll("\\|", URLEncoder.encode("|", Charset.defaultCharset())); - String finalReqScope = reqScope; + final String finalReqScope = reqScope; partitionedConceptReferenceComponents.forEach(batch -> { - ValueSet.ConceptSetComponent batchedConceptSetComponent = new ValueSet.ConceptSetComponent(); + final ValueSet.ConceptSetComponent batchedConceptSetComponent = new ValueSet.ConceptSetComponent(); batchedConceptSetComponent.setSystem(DEFAULT_CODE_SYSTEM); batchedConceptSetComponent.setVersion(codeSystemVersion); batchedConceptSetComponent.setValueSet(List.of(new CanonicalType(finalReqScope))); @@ -85,15 +92,15 @@ public ValidationResult validateValueSetComposition( valueSetToExpand.getCompose().setInclude(conceptSetComponents); } int count = 0, offset = 0; - TerminologyClient terminologyClient = terminologyProvider.getClient(); + final TerminologyClient terminologyClient = terminologyProvider.getClient(); ValueSet responseVs = new ValueSet(); try { responseVs = terminologyClient.expand(valueSetToExpand, new IntegerType(count), new IntegerType(offset), new BooleanType(false)); - } catch (ResourceNotFoundException e) { + } catch (final ResourceNotFoundException e) { responseVs.setExpansion(new ValueSet.ValueSetExpansionComponent()); } - ValueSet.ValueSetExpansionComponent expansion = responseVs.getExpansion(); - List contains = new ArrayList<>(); + final ValueSet.ValueSetExpansionComponent expansion = responseVs.getExpansion(); + final List contains = new ArrayList<>(); while (contains.size() < expansion.getTotal()) { offset = contains.size(); @@ -101,9 +108,9 @@ public ValidationResult validateValueSetComposition( if ((offset + count) > expansion.getTotal()) { count = expansion.getTotal() - offset; } - List responseVss = new ArrayList<>(); - int finalCount = count; - int finalOffset = offset; + final List responseVss = new ArrayList<>(); + final int finalCount = count; + final int finalOffset = offset; conceptSetComponents.forEach(component -> { valueSetToExpand.getCompose().setInclude(List.of(component)); responseVss.add(terminologyClient.expand(valueSetToExpand, new IntegerType(finalCount), new IntegerType(finalOffset), @@ -112,13 +119,13 @@ public ValidationResult validateValueSetComposition( responseVss.forEach(item -> contains.addAll(item.getExpansion().getContains())); } - Set inactive = contains.stream() + final Set inactive = contains.stream() .filter(ValueSet.ValueSetExpansionContainsComponent::getInactive) .map(ValueSet.ValueSetExpansionContainsComponent::getCode).collect(Collectors.toSet()); - Set active = contains.stream() + final Set active = contains.stream() .filter(entry -> !entry.getInactive()) .map(ValueSet.ValueSetExpansionContainsComponent::getCode).collect(Collectors.toSet()); - Set absent = new HashSet(codes); + final Set absent = new HashSet(codes); absent.removeAll(inactive); absent.removeAll(active); return new ValidationResult(active.size(), inactive, absent, invalid); @@ -127,7 +134,7 @@ public ValidationResult validateValueSetComposition( public static boolean isValidSctId(String code, RF2SchemaConstants.PartionIdentifier partition) { try { SnomedUtils.isValid(code, partition, true); - } catch (Exception e) { + } catch (final Exception e) { return false; } return true; diff --git a/api/src/main/java/org/snomed/snap2snomed/service/MapViewService.java b/api/src/main/java/org/snomed/snap2snomed/service/MapViewService.java index c14b1dff..2f2b8b57 100644 --- a/api/src/main/java/org/snomed/snap2snomed/service/MapViewService.java +++ b/api/src/main/java/org/snomed/snap2snomed/service/MapViewService.java @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package org.snomed.snap2snomed.service; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.function.BiFunction; @@ -32,6 +33,7 @@ import org.snomed.snap2snomed.model.AdditionalCodeColumn; import org.snomed.snap2snomed.model.Map; import org.snomed.snap2snomed.model.MapView; +import org.snomed.snap2snomed.model.QDbMapView; import org.snomed.snap2snomed.model.QImportedCode; import org.snomed.snap2snomed.model.QMapRow; import org.snomed.snap2snomed.model.QMapRowTarget; @@ -41,10 +43,13 @@ import org.snomed.snap2snomed.model.enumeration.ColumnType; import org.snomed.snap2snomed.model.enumeration.MapStatus; import org.snomed.snap2snomed.model.enumeration.MappingRelationship; +import org.snomed.snap2snomed.model.enumeration.NoteCategory; import org.snomed.snap2snomed.model.enumeration.TaskType; import org.snomed.snap2snomed.problem.auth.NotAuthorisedProblem; +import org.snomed.snap2snomed.repository.DbMapViewRepository; import org.snomed.snap2snomed.repository.MapRepository; import org.snomed.snap2snomed.repository.TaskRepository; +import org.snomed.snap2snomed.security.AuthenticationFacade; import org.snomed.snap2snomed.security.WebSecurity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -79,6 +84,7 @@ public class MapViewService { private static final String ADDITIONAL_COLUMN_NAME = "additionalColumn"; + private static final String TARGET_OUT_OF_SCOPE_TAG = "target-out-of-scope"; public class MapViewFilter { @@ -94,13 +100,16 @@ public class MapViewFilter { private final List lastAuthorReviewer; private final List assignedAuthor; private final List assignedReviewer; + private final List assignedReconciler; + private final Boolean targetOutOfScope; private final Boolean flagged; private final List additionalColumns; public MapViewFilter(List sourceCodes, List sourceDisplays, Boolean noMap, List targetCodes, - List targetDisplays, List relationshipTypes, List statuses, List lastAuthor, - List lastReviewer, List lastAuthorReviewer, List assignedAuthor, List assignedReviewer, - Boolean flagged, List additionalColumns) { + List targetDisplays, List relationshipTypes, List statuses, + List lastAuthor, List lastReviewer, List lastAuthorReviewer, + List assignedAuthor, List assignedReviewer, List assignedReconciler, + Boolean targetOutOfScope, Boolean flagged, List additionalColumns) { this.sourceCodes = sourceCodes; this.sourceDisplays = sourceDisplays; this.noMap = noMap; @@ -113,23 +122,26 @@ public MapViewFilter(List sourceCodes, List sourceDisplays, Bool this.lastAuthorReviewer = lastAuthorReviewer; this.assignedAuthor = assignedAuthor; this.assignedReviewer = assignedReviewer; + this.assignedReconciler = assignedReconciler; + this.targetOutOfScope = targetOutOfScope; this.flagged = flagged; this.additionalColumns = additionalColumns; } - public BooleanExpression getExpression() { + public BooleanExpression getExpression(boolean useDualView) { + var _mapRow = useDualView ? QDbMapView.dbMapView.mapRow : QMapRow.mapRow; BooleanExpression expression = null; - + expression = stringCollectionToOrStatements(expression, sourceCodes, - s -> QMapRow.mapRow.sourceCode.code.startsWithIgnoreCase(s), + s -> _mapRow.sourceCode.code.startsWithIgnoreCase(s), (a, b) -> collectOrStatement(a, b)); expression = stringCollectionToOrStatements(expression, sourceDisplays, - s -> QMapRow.mapRow.sourceCode.display.containsIgnoreCase(s), + s -> _mapRow.sourceCode.display.containsIgnoreCase(s), (a, b) -> collectAndStatement(a, b)); if (noMap != null) { - expression = collectAndStatement(expression, QMapRow.mapRow.noMap.eq(noMap)); + expression = collectAndStatement(expression, _mapRow.noMap.eq(noMap)); } expression = stringCollectionToOrStatements(expression, targetCodes, @@ -144,48 +156,67 @@ public BooleanExpression getExpression() { } if (!CollectionUtils.isEmpty(statuses)) { - expression = collectAndStatement(expression, QMapRow.mapRow.status.in(statuses)); + expression = collectAndStatement(expression, _mapRow.status.in(statuses)); } if (!CollectionUtils.isEmpty(lastAuthor)) { - expression = collectAndStatement(expression, QMapRow.mapRow.lastAuthor.id.in(lastAuthor)); + expression = collectAndStatement(expression, _mapRow.lastAuthor.id.in(lastAuthor)); } if (!CollectionUtils.isEmpty(lastReviewer)) { - expression = collectAndStatement(expression, QMapRow.mapRow.lastReviewer.id.in(lastReviewer)); + expression = collectAndStatement(expression, _mapRow.lastReviewer.id.in(lastReviewer)); } if (!CollectionUtils.isEmpty(lastAuthorReviewer)) { BooleanExpression noneMatch = null; if (lastAuthorReviewer.contains("none")) { - noneMatch = QMapRow.mapRow.lastAuthor.isNull().and(QMapRow.mapRow.lastReviewer.isNull()); + noneMatch = _mapRow.lastAuthor.isNull().and(_mapRow.lastReviewer.isNull()); } expression = collectAndStatement(expression, - collectOrStatement(QMapRow.mapRow.lastAuthor.id.in(lastAuthorReviewer).or(QMapRow.mapRow.lastReviewer.id.in(lastAuthorReviewer)), + collectOrStatement(_mapRow.lastAuthor.id.in(lastAuthorReviewer).or(_mapRow.lastReviewer.id.in(lastAuthorReviewer)), noneMatch)); } if (!CollectionUtils.isEmpty(assignedAuthor)) { BooleanExpression noneMatch = null; if (assignedAuthor.contains("none")) { - noneMatch = QMapRow.mapRow.authorTask.isNull(); + noneMatch = _mapRow.authorTask.isNull(); } - expression = collectAndStatement(expression, collectOrStatement(QMapRow.mapRow.authorTask.assignee.id.in(assignedAuthor), + expression = collectAndStatement(expression, collectOrStatement(_mapRow.authorTask.assignee.id.in(assignedAuthor), noneMatch)); } if (!CollectionUtils.isEmpty(assignedReviewer)) { BooleanExpression noneMatch = null; if (assignedReviewer.contains("none")) { - noneMatch = QMapRow.mapRow.reviewTask.assignee.isNull(); + noneMatch = _mapRow.reviewTask.assignee.isNull(); } - expression = collectAndStatement(expression, collectOrStatement(QMapRow.mapRow.reviewTask.assignee.id.in(assignedReviewer), + expression = collectAndStatement(expression, collectOrStatement(_mapRow.reviewTask.assignee.id.in(assignedReviewer), noneMatch)); } + if (!CollectionUtils.isEmpty(assignedReconciler)) { + BooleanExpression noneMatch = null; + if (assignedReconciler.contains("none")) { + noneMatch = _mapRow.reconcileTask.assignee.isNull(); + } + + expression = collectAndStatement(expression, collectOrStatement(_mapRow.reconcileTask.assignee.id.in(assignedReconciler), + noneMatch)); + } + + if (targetOutOfScope != null) { + if (targetOutOfScope) { + expression = collectAndStatement(expression, QMapRowTarget.mapRowTarget.tags.contains(TARGET_OUT_OF_SCOPE_TAG)); + } + else { + expression = collectAndStatement(expression, QMapRowTarget.mapRowTarget.tags.contains(TARGET_OUT_OF_SCOPE_TAG).not()); + } + } + if (flagged != null) { expression = collectAndStatement(expression, QMapRowTarget.mapRowTarget.flagged.eq(flagged)); } @@ -194,7 +225,7 @@ public BooleanExpression getExpression() { for (int i = 0; i < additionalColumns.size(); i++) { final String string = additionalColumns.get(i); if (!string.isEmpty()) { - expression = collectAndStatement(expression, QMapRow.mapRow.sourceCode.additionalColumns.get(i).value.containsIgnoreCase(string)); + expression = collectAndStatement(expression, _mapRow.sourceCode.additionalColumns.get(i).value.containsIgnoreCase(string)); } } } @@ -228,6 +259,13 @@ private BooleanExpression stringCollectionToOrStatements(BooleanExpression expre @Autowired WebSecurity webSecurity; + @Autowired + AuthenticationFacade authenticationFacade; + + @Autowired + DbMapViewRepository mapViewRepository; + + private final QDbMapView mapView = QDbMapView.dbMapView; private final QMapRow mapRow = QMapRow.mapRow; private final QMapRowTarget mapTarget = QMapRowTarget.mapRowTarget; private final QNote note = QNote.note; @@ -267,23 +305,87 @@ public String getFileNameForMapExport(Long mapId, String contentType) { extension = ".xlsx"; break; + case MapViewRestController.FHIR_JSON: + extension = ".json"; + break; + default: - throw Problem.valueOf(Status.UNSUPPORTED_MEDIA_TYPE, "Content type " + contentType + " is not supported"); + throw Problem.valueOf(Status.UNSUPPORTED_MEDIA_TYPE, "Content type " + contentType + " is not supported for map export"); } return "map-" + map.getProject().getTitle() + "_" + map.getMapVersion() + extension; } + public List getAdditionalColumnsMetadata(Long mapId) { + return mapRepository.findSourceByMapId(mapId).get().getAdditionalColumnsMetadata(); + } + + public String[] getExportHeader(Long mapId, List extraColumns) { + + ArrayList exportHeader = new ArrayList(Arrays.asList("\ufeff" + "Source code", "Source display")); + + final List additionalCodeColumnList = this.getAdditionalColumnsMetadata(mapId); + if (additionalCodeColumnList != null && additionalCodeColumnList.size() > 0) { + for (AdditionalCodeColumn additionalColumn : additionalCodeColumnList) { + exportHeader.add(additionalColumn.getName()); + } + } + exportHeader.addAll(Arrays.asList("Target code", "Target display", "Relationship type code", "Relationship type display", "No map flag", "Status")); + + if (extraColumns != null) { + for (String extraColumn : extraColumns) { + switch(extraColumn.toUpperCase()) { + case "NOTES": + exportHeader.add("Notes"); + break; + case "ASSIGNEDAUTHOR": + exportHeader.add("Assigned author"); + break; + case "ASSIGNEDREVIEWER": + exportHeader.add("Assigned reviewer"); + break; + case "LASTAUTHOR": + exportHeader.add("Last author"); + break; + case "LASTREVIEWER": + exportHeader.add("Last reviewer"); + break; + } + } + } + + return exportHeader.toArray(new String[0]); + + } + public List getAllMapViewForMap(Long mapId) { - return getQueryForMap(mapId, null, null).orderBy(mapRow.sourceCode.index.asc()).orderBy(mapTarget.id.asc()).fetch(); + final Map map = mapRepository.findById(mapId).orElseThrow(() -> Problem.valueOf(Status.NOT_FOUND, "No Map found with id " + mapId)); + Boolean dualMapMode = map.getProject().getDualMapMode(); + if (dualMapMode) { + return getDualMapQueryForMap(mapId, null, null, null).fetch(); + } + else { + return getQueryForMap(mapId, null, null).orderBy(mapRow.sourceCode.index.asc()).orderBy(mapTarget.id.asc()).fetch(); + } + } private Snap2SnomedPagedModel> getMapResults(Long mapId, Task task, Pageable pageable, PagedResourcesAssembler assembler, MapViewFilter filter) { final List additionalColumns = mapRepository.findSourceByMapId(mapId).get().getAdditionalColumnsMetadata(); - JPAQuery query = getQueryForMap(mapId, task, filter); - query = transformSortable(query, pageable.getSort(), additionalColumns); + final Map map = mapRepository.findById(mapId).orElseThrow(() -> Problem.valueOf(Status.NOT_FOUND, "No Map found with id " + mapId)); + Boolean dualMapMode = map.getProject().getDualMapMode(); + + JPAQuery query; + if (dualMapMode) { + query = getDualMapQueryForMap(mapId, task, filter, pageable.getSort()); + } + else { + query = getQueryForMap(mapId, task, filter); + } + + query = transformSortable(query, pageable.getSort(), additionalColumns, dualMapMode, task); query = transformPageable(query, pageable); final QueryResults results = query.fetchResults(); @@ -305,25 +407,27 @@ protected JPAQuery transformPageable(JPAQuery query, Pageable return query; } - protected JPAQuery transformSortable(JPAQuery query, Sort sort, List additionalColumns) { + protected JPAQuery transformSortable(JPAQuery query, Sort sort, List additionalColumns, Boolean dualMapMode, + Task task) { if (sort != null) { + var _mapRow = dualMapMode && task == null ? mapView.mapRow : mapRow; for (final Order s : sort) { List> field; switch (s.getProperty()) { case "rowId": - field = Arrays.asList(mapRow.id); + field = Arrays.asList(_mapRow.id); break; case "sourceIndex": - field = Arrays.asList(mapRow.sourceCode.index); + field = Arrays.asList(_mapRow.sourceCode.index); break; case "sourceCode": - field = Arrays.asList(mapRow.sourceCode.code); + field = Arrays.asList(_mapRow.sourceCode.code); break; case "sourceDisplay": - field = Arrays.asList(mapRow.sourceCode.display); + field = Arrays.asList(_mapRow.sourceCode.display); break; case "noMap": - field = Arrays.asList(mapRow.noMap); + field = Arrays.asList(_mapRow.noMap); break; case "targetId": field = Arrays.asList(mapTarget.id); @@ -338,27 +442,35 @@ protected JPAQuery transformSortable(JPAQuery query, Sort sort field = Arrays.asList(mapTarget.relationship); break; case "status": - field = Arrays.asList(mapRow.status); + field = Arrays.asList(_mapRow.status); break; case "latestNote": field = Arrays.asList(Expressions.dateTimePath(ZonedDateTime.class, "latestNote")); break; case "assignedAuthor": - field = Arrays.asList(getUserSortComparison(mapRow.authorTask.assignee)); + field = Arrays.asList(getUserSortComparison(_mapRow.authorTask.assignee)); + break; + case "assignedReconciler": + field = Arrays.asList(getUserSortComparison(_mapRow.reconcileTask.assignee)); break; case "assignedReviewer": - field = Arrays.asList(getUserSortComparison(mapRow.reviewTask.assignee)); + field = Arrays.asList(getUserSortComparison(_mapRow.reviewTask.assignee)); break; case "lastAuthor": - field = Arrays.asList(getUserSortComparison(mapRow.lastAuthor)); + field = Arrays.asList(getUserSortComparison(_mapRow.lastAuthor)); break; case "lastReviewer": - field = Arrays.asList(getUserSortComparison(mapRow.lastReviewer)); + field = Arrays.asList(getUserSortComparison(_mapRow.lastReviewer)); break; case "lastAuthorReviewer": field = Arrays.asList( - getUserSortComparison(mapRow.lastAuthor), - getUserSortComparison(mapRow.lastReviewer)); + getUserSortComparison(_mapRow.lastAuthor), + getUserSortComparison(_mapRow.lastReviewer)); + break; + case "targetOutOfScope": + field = null; + // it does not make sense to sort by this flag so it is not supported + log.warn("Unsupported MapView sort field '" + s.getProperty() + "' - ignored"); break; case "flagged": field = Arrays.asList(mapTarget.flagged); @@ -368,7 +480,7 @@ protected JPAQuery transformSortable(JPAQuery query, Sort sort if (s.getProperty().startsWith(ADDITIONAL_COLUMN_NAME)) { final int index = Integer.parseInt(s.getProperty().substring(ADDITIONAL_COLUMN_NAME.length())) - 1; final ColumnType type = additionalColumns.get(index).getType(); - field = Arrays.asList(getSortExpression(mapRow.sourceCode, type, index)); + field = Arrays.asList(getSortExpression(_mapRow.sourceCode, type, index)); } else { field = null; log.warn("Unknown MapView sort field '" + s.getProperty() + "' - ignored"); @@ -406,23 +518,77 @@ protected List toRepList(Stream stream) { return stream.map(RepresentationModel::of).collect(Collectors.toList()); } + private JPAQuery getDualMapQueryForMap(Long mapId, Task task, MapViewFilter filter, Sort sort) { + + if (task != null) { + + //TODO maybe two queries here removing unneeded joins? + + // details / task screen .. don't display reconcile state or reconciled (mapped) + JPAQuery query = new JPAQuery(entityManager) + .select(Projections.constructor(MapView.class, mapRow, mapTarget, + ExpressionUtils.as(JPAExpressions.select(note.modified.max()).from(note) + .where(note.mapRow.eq(mapRow).and(note.category.eq(NoteCategory.USER)).and(note.deleted.isFalse())), "latestNote"), + mapRow.status)) + .from(mapRow) + .leftJoin(mapTarget).on(mapTarget.row.eq(mapRow)) + .leftJoin(mapRow.authorTask) + .leftJoin(mapRow.reviewTask) + .leftJoin(mapRow.reconcileTask) + .leftJoin(mapRow.lastAuthor) + .leftJoin(mapRow.lastReviewer) + .where(getWhereClause(mapId, task, filter, false)); + + if ((task.getType().equals(TaskType.RECONCILE) || task.getType().equals(TaskType.AUTHOR)) && sort != null && sort.isUnsorted()) { + query = query.orderBy(mapRow.sourceCode.index.asc()).orderBy((mapRow.lastAuthor.id.asc())); + } + + return query; + } + else { + // view screen + JPAQuery query = new JPAQuery(entityManager) + .select(Projections.constructor(MapView.class, mapView.mapRow, mapTarget, + ExpressionUtils.as(JPAExpressions.select(note.modified.max()).from(note) + .where(note.mapRow.eq(mapView.mapRow).and(note.category.eq(NoteCategory.USER)).and(note.deleted.isFalse())), "latestNote"), + mapView.status, mapView.siblingRowAuthorTask)) + .from(mapView) + .leftJoin(mapTarget).on(mapTarget.row.eq(mapView.mapRow).and(mapView.blindMapFlag.eq(false))) + .leftJoin(mapView.mapRow.authorTask) + .leftJoin(mapView.mapRow.reviewTask) + .leftJoin(mapView.mapRow.reconcileTask) + .leftJoin(mapView.mapRow.lastAuthor) + .leftJoin(mapView.mapRow.lastReviewer) + .leftJoin(mapView.siblingRowAuthorTask) + .where(getMapViewWhereClause(mapId, task, filter)); + + if (sort == null || sort.isUnsorted()) { + query = query.orderBy(mapView.mapRow.sourceCode.index.asc()).orderBy(mapView.mapRow.lastAuthor.id.asc()); + } + + return query; + } + } + protected JPAQuery getQueryForMap(Long mapId, Task task, MapViewFilter filter) { return new JPAQuery(entityManager) .select(Projections.constructor(MapView.class, mapRow, mapTarget, ExpressionUtils.as(JPAExpressions.select(note.modified.max()).from(note) - .where(note.mapRow.eq(mapRow).and(note.deleted.isFalse())), "latestNote"))) + .where(note.mapRow.eq(mapRow).and(note.category.eq(NoteCategory.USER)).and(note.deleted.isFalse())), "latestNote"))) .from(mapRow) .leftJoin(mapTarget).on(mapTarget.row.eq(mapRow)) .leftJoin(mapRow.authorTask) .leftJoin(mapRow.reviewTask) .leftJoin(mapRow.lastAuthor) .leftJoin(mapRow.lastReviewer) - .where(getWhereClause(mapId, task, filter)); + .where(getWhereClause(mapId, task, filter, false)) + .where(mapRow.blindMapFlag.eq(false)); } protected JPAQuery getQueryMappedRowDetailsForMap(Long mapId, Task task, MapViewFilter filter, - Pageable pageable ) { + Pageable pageable) { + final JPAQuery query = new JPAQuery(entityManager) .select(Projections.constructor(MappedRowDetailsDto.class, mapRow.id, mapRow.sourceCode.index, mapTarget.id)) .from(mapRow) @@ -430,27 +596,53 @@ protected JPAQuery getQueryMappedRowDetailsForMap(Long mapI if (task != null) { query.leftJoin(mapRow.authorTask) .leftJoin(mapRow.reviewTask) + .leftJoin(mapRow.reconcileTask) .leftJoin(mapRow.lastAuthor) .leftJoin(mapRow.lastReviewer); } - query.where(getWhereClause(mapId, task, filter)); + query.where(getWhereClause(mapId, task, filter, false)); query.offset(pageable.getOffset()).limit(pageable.getPageSize()); return query; } - private BooleanExpression getWhereClause(Long mapId, Task task, MapViewFilter filter) { + private BooleanExpression getMapViewWhereClause(Long mapId, Task task, MapViewFilter filter) { + BooleanExpression whereClause = mapView.mapRow.map.id.eq(mapId); + + if (filter != null) { + final BooleanExpression filterExpression = filter.getExpression(true); + if (filterExpression != null) { + whereClause = whereClause.and(filterExpression); + } + } + + return whereClause; + } + + private BooleanExpression getWhereClause(Long mapId, Task task, MapViewFilter filter, boolean useDualView) { BooleanExpression whereClause = mapRow.map.id.eq(mapId); if (task != null) { if (task.getType().equals(TaskType.AUTHOR)) { whereClause = whereClause.and(mapRow.authorTask.eq(task)); - } else { + if (task.getMap().getProject().getDualMapMode()) { + whereClause = whereClause.and(mapRow.blindMapFlag.eq(true)); + } + } else if (task.getType().equals(TaskType.REVIEW)) { whereClause = whereClause.and(mapRow.reviewTask.eq(task)); + whereClause = whereClause.and(mapRow.status.ne(MapStatus.RECONCILE)); + if (task.getMap().getProject().getDualMapMode()) { + whereClause = whereClause.and(mapRow.blindMapFlag.ne(true)); + } + } + else if (task.getType().equals(TaskType.RECONCILE)) { + whereClause = whereClause.and(mapRow.blindMapFlag.eq(false)); + whereClause = whereClause.and(mapRow.reconcileTask.eq(task)); + whereClause = whereClause.and(mapRow.status.eq(MapStatus.RECONCILE)); } } if (filter != null) { - final BooleanExpression filterExpression = filter.getExpression(); + final BooleanExpression filterExpression = filter.getExpression(useDualView); if (filterExpression != null) { whereClause = whereClause.and(filterExpression); } @@ -473,4 +665,28 @@ private BooleanExpression collectAndStatement(BooleanExpression expression, Bool return expression.and(betweenStatement); } + public MapView getDualMapSiblingRow(Long mapId, Long sourceCodeId, Long mapRowId) { + + if (!mapRepository.existsById(mapId)) { + throw Problem.valueOf(Status.NOT_FOUND, "No Map found with id " + mapId); + } + + JPAQuery query = new JPAQuery(entityManager) + .select(Projections.constructor(MapView.class, mapRow, mapTarget, + ExpressionUtils.as(JPAExpressions.select(note.modified.max()).from(note) + .where(note.mapRow.eq(mapRow).and(note.category.eq(NoteCategory.USER)).and(note.deleted.isFalse())), "latestNote"))) + .from(mapRow) + .leftJoin(mapTarget).on(mapTarget.row.eq(mapRow)) + .where(mapRow.map.id.eq(mapId)) + .where(mapRow.id.ne(mapRowId)) + .where(mapRow.sourceCode.id.eq(sourceCodeId)); + + QueryResults queryResults = query.fetchResults(); + if (queryResults.getResults().size() > 0) { + return queryResults.getResults().get(0); + } + return null; + + } + } diff --git a/api/src/main/java/org/snomed/snap2snomed/service/MappingService.java b/api/src/main/java/org/snomed/snap2snomed/service/MappingService.java index ba91291c..1bdc4ee4 100644 --- a/api/src/main/java/org/snomed/snap2snomed/service/MappingService.java +++ b/api/src/main/java/org/snomed/snap2snomed/service/MappingService.java @@ -136,9 +136,14 @@ public MappingResponse updateMapping(MappingUpdateDto mappings) { "Only an owner can perform bulk updates outside of the context of a task"); } + List siblingMapRowIds = new ArrayList(); mappings.getMappingDetails().forEach(mappingDetails -> { validateMappingUpdates(mappingDetails.getMappingUpdate()); + if (mappingDetails.getMappingUpdate().isResetDualMap() && siblingMapRowIds.contains(mappingDetails.getRowId())) { + return; // skip this iteration as have already processed one of the two dual map rows + } + MapRow mapRow = mapRowRepository .findById(mappingDetails.getRowId()) .orElseThrow(() -> new InvalidMappingProblem("MapRow", mappingDetails.getRowId())); @@ -175,10 +180,17 @@ public MappingResponse updateMapping(MappingUpdateDto mappings) { rowCount.incrementAndGet(); // Increment row count for Row RowChange change = applyMapRowChanges(mapRow, mappingDetails.getMappingUpdate(), mapRowTargets, task); if ( change != RowChange.NO_CHANGE && (mapRowTargets.size() == 0 - || (mapRowTargets.size() == 0 && mappingDetails.getMappingUpdate().getStatus() != null))) { + || (mappingDetails.getMappingUpdate().isResetDualMap()))) { + if (mappingDetails.getMappingUpdate().isResetDualMap()) { + MapRow siblingMapRow = mapRowRepository.findDualMapSiblingRow(mapRow.getMap().getId(), mapRow.getSourceCode().getId(), mapRow.getId()); + if (siblingMapRow != null) { + // keep a track fo the sibling rows so we don't attempt to process them if multiple rows have been supplied + siblingMapRowIds.add(siblingMapRow.getId()); + } + } updatedRowCount.incrementAndGet(); // Only add updated count if there is not MapRowTargets } - if (mapRowTargets.size() > 0) { + if (mapRowTargets.size() > 0 && !mappingDetails.getMappingUpdate().isResetDualMap()) { if (applyMapRowTargetChanges(mapRowTargets.get(0), mappingDetails.getMappingUpdate(), task, change)) { updatedRowCount.incrementAndGet(); } @@ -191,6 +203,60 @@ public MappingResponse updateMapping(MappingUpdateDto mappings) { .build(); } + void processMapRowUpdate(TargetDto targetDto, MapRow mapRow, MappingDetails mappingDetail, List mappingDetails, Long targetRowId) { + Long targetId; + Boolean noMap = mappingDetail.getMappingUpdate().getNoMap(); + MapStatus mapStatus = mappingDetail.getMappingUpdate().getStatus(); + + if (targetDto == null) { + targetId = targetRowId; + } + else { + MappedRowDetailsDto mappedRowDetailsDto = MappedRowDetailsDto.builder().mapRowId(mapRow.getId()).sourceIndex(mappingDetail.getRowId()).mapRowTargetId(targetRowId).build(); + targetId = createTarget(mappingDetail, mappedRowDetailsDto); + noMap = false; + mapStatus = MapStatus.DRAFT; + } + mappingDetails.add( + MappingDetails.builder() + .rowId(mapRow.getId()) + .taskId(mappingDetail.getTaskId()) + .mappingUpdate(MappingDto.builder() + .noMap(noMap) + .relationship(mappingDetail.getMappingUpdate().getRelationship()) + .status(mapStatus) + .clearTarget(mappingDetail.getMappingUpdate().getClearTarget()) + .targetId(targetId) + .target(targetDto) + .resetDualMap(mappingDetail.getMappingUpdate().isResetDualMap()) + .build() + ) + .build()); + } + + @Transactional + public MappingResponse updateMappingForAll(Long mapId, MappingUpdateDto mappings) { + MappingUpdateDto mapUpdate = new MappingUpdateDto(); + List mappingDetails = new ArrayList<>(); + if (!(mappings.getMappingDetails() == null || mappings.getMappingDetails().isEmpty())) { + mappings.getMappingDetails().forEach(mappingDetail -> { + TargetDto targetDto = mappingDetail.getMappingUpdate().getTarget(); + Collection mapRows = mapRowRepository.findMapRowsByMapId(mapId); + mapRows.forEach(mapRow -> { + if (mapRow.getMapRowTargets().size() <= 0) { + processMapRowUpdate(targetDto, mapRow, mappingDetail, mappingDetails, null); + } + mapRow.getMapRowTargets().forEach(subSelection -> { + processMapRowUpdate(targetDto, mapRow, mappingDetail, mappingDetails, subSelection.getId()); + }); + }); + }); + mapUpdate.setMappingDetails(mappingDetails); + } + return this.updateMapping(mapUpdate); + + } + @Transactional public MappingResponse updateMappingForSelection(MappingUpdateDto mappings) { MappingUpdateDto mapUpdate = new MappingUpdateDto(); @@ -219,6 +285,7 @@ public MappingResponse updateMappingForSelection(MappingUpdateDto mappings) { .relationship(mappingDetail.getMappingUpdate().getRelationship()) .status(mapStatus) .clearTarget(mappingDetail.getMappingUpdate().getClearTarget()) + .resetDualMap(mappingDetail.getMappingUpdate().isResetDualMap()) .targetId(targetId) .target(targetDto) .build() @@ -334,6 +401,17 @@ private MappingResponse updateMapRowsForMappingDto(MappingDto mappingUpdate, Tas .build(); } + private void resetDualMapRow(MapRow mapRow) { + mapRow.setNoMap(false); + mapRow.setStatus(MapStatus.UNMAPPED); + mapRow.setAuthorTask(null); + mapRow.setLastAuthor(null); + mapRow.setLastReviewer(null); + mapRow.setReviewTask(null); + mapRow.setBlindMapFlag(true); + mapRow.setReconcileTask(null); + } + private RowChange applyMapRowChanges(@NotNull MapRow mapRow, MappingDto mappingUpd, List mapRowTargets, Task task) { MappingDto mappingUpdate = mappingUpd.toBuilder().build(); boolean updated = false; @@ -368,9 +446,50 @@ private RowChange applyMapRowChanges(@NotNull MapRow mapRow, MappingDto mappingU mapRow.setStatus(MapStatus.UNMAPPED); } } - // Repository.save doesn't fire MapRowEventHandler - mapRowEventHandler.performAutomaticUpdates(mapRow); + // Reset dual mapping requested + MapRow siblingMapRow = null; + if (mappingUpdate.isResetDualMap()) { + + // locate the sibling row if it exists update it too + siblingMapRow = mapRowRepository.findDualMapSiblingRow(mapRow.getMap().getId(), mapRow.getSourceCode().getId(), mapRow.getId()); + if (siblingMapRow == null) { + // re-create the sibling row + MapRow newSiblingMapRow = new MapRow(); + newSiblingMapRow.setMap(mapRow.getMap()); + newSiblingMapRow.setSourceCode(mapRow.getSourceCode()); + newSiblingMapRow.setBlindMapFlag(true); + mapRowRepository.save(newSiblingMapRow); + } + + // reset the MapRow + resetDualMapRow(mapRow); + if (siblingMapRow != null) { + resetDualMapRow(siblingMapRow); + } + + // delete associated mapRowTargets + mapRowTargets.forEach(mapRowTarget -> { + mapRowTargetRepository.delete(mapRowTarget); + }); + if (siblingMapRow != null) { + siblingMapRow.getMapRowTargets().forEach(mapRowTarget -> { + mapRowTargetRepository.delete(mapRowTarget); + }); + } + + updated = true; + + } + else { + // Repository.save doesn't fire MapRowEventHandler + // none of these automatic updates apply to resetting a dual map + mapRowEventHandler.performAutomaticUpdates(mapRow); + } + mapRowRepository.save(mapRow); + if (siblingMapRow != null) { + mapRowRepository.save(siblingMapRow); + } em.flush(); em.clear(); } @@ -424,7 +543,7 @@ private boolean validChange(MapStatus currentStatus, MappingDto mappingUpd, Task } else { // no task, must be an owner return mappingUpd.isOnlyStatusChange() ? currentStatus.isValidTransition(mappingUpd.getStatus()) - : currentStatus.isValidTransition(MapStatus.DRAFT); + : (currentStatus.isValidTransition(MapStatus.DRAFT) || currentStatus.isValidTransition(MapStatus.UNMAPPED)); } } @@ -487,6 +606,8 @@ private boolean rowIsAssignedToTask(Task task, MapRow mapRow) { return mapRow.getAuthorTask() != null && mapRow.getAuthorTask().getId().equals(task.getId()); case REVIEW: return mapRow.getReviewTask() != null && mapRow.getReviewTask().getId().equals(task.getId()); + case RECONCILE: + return mapRow.getReconcileTask() != null && mapRow.getReconcileTask().getId().equals(task.getId()); default: throw new IllegalArgumentException("Unknown task type " + task.getType()); } @@ -524,17 +645,45 @@ public Long newMappingVersion(Long mapId, Long newSourceId, String mapVersion, S final Map mapCreated = mapRepository.save(newMap); final Long createdId = mapCreated.getId(); + // NB, for dual maps where there can be multiple rows, there is no way of determining via SQL which new map_rows correspond with + // which original map_row. As a temporary workaround, I've changed the modified date on the new map_row to be the same as the + // original map_row to create something to link them. Multiple rows should always have different dates once unblinded as both + // rows would have been modified by real people. + if (newSourceId == originalSourceId) { // Copy all rows mapRowRepository.copyMapRows(createdId, mapId, userId, dateTime); - mapRowTargetRepository.copyMapRowTargets(createdId, mapId, userId, dateTime); + if (originalMap.getProject().getDualMapMode()) { + mapRowTargetRepository.copyMapRowTargetsForDualMap(createdId, mapId, userId, dateTime); + } + else { + mapRowTargetRepository.copyMapRowTargets(createdId, mapId, userId, dateTime); + } + } else { // Copy all rows where code in new Source mapRowRepository.copyMapRowsForNewSource(createdId, mapId, userId, dateTime, newSourceId); // Copy existing targets - mapRowTargetRepository.copyMapRowTargetsForNewSource(createdId, mapId, userId, dateTime); + if (originalMap.getProject().getDualMapMode()) { + mapRowTargetRepository.copyMapRowTargetsForNewSourceForDualMap(createdId, mapId, userId, dateTime); + } + else { + mapRowTargetRepository.copyMapRowTargetsForNewSource(createdId, mapId, userId, dateTime); + } // Add new rows where previously non-existing - mapRowRepository.createMapRowsForNewSource(createdId, userId, dateTime, newSourceId); + + if (originalMap.getProject().getDualMapMode()) { + mapRowRepository.createMapRowsForNewSourceForDualMap(createdId, userId, dateTime, newSourceId); + } + else { + mapRowRepository.createMapRowsForNewSource(createdId, userId, dateTime, newSourceId); + } + } + + if (originalMap.getProject().getDualMapMode()) { + // Any rows in draft state will be cleared and reblinded + mapRowRepository.resetMapRowResetRowsForNewMap(createdId); + } validateMapTargets(createdId); @@ -569,7 +718,7 @@ public ValidationResult validateMapTargets(Long mapId) throws IOException { .filter(target -> targetsToFlag.contains(target.getTargetCode())) .map(MapRowTarget::getId) .collect(Collectors.toList()); - mapRowTargetRepository.flagMapTargets(targetIds, userId, Instant.now()); + mapRowTargetRepository.addOutOfScopeTag(targetIds); } return validationResult; } diff --git a/api/src/main/resources/application-local.properties b/api/src/main/resources/application-local.properties index 41eeaf45..3263bb7b 100644 --- a/api/src/main/resources/application-local.properties +++ b/api/src/main/resources/application-local.properties @@ -1,12 +1,10 @@ -spring.datasource.username=sa -spring.datasource.password=password -spring.datasource.url=jdbc:h2:mem:testdb;DATABASE_TO_UPPER=FALSE -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -##spring.datasource.url=jdbc:mariadb://127.0.0.1/snap2snomed -##spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect -## TODO configure me for the OIDC Discovery endpoint or OAuth 2.0 Authorisation Server Metadata endpoint -spring.security.oauth2.resourceserver.jwt.issuer-uri=https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_oQSXJHFz9 +spring.datasource.driverClassName=software.aws.rds.jdbc.mysql.Driver +spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect +snap2snomed.cors.allowedOriginPatterns=* snap2snomed.security.authDomainUrl=https://snap-2-snomed-test.auth.ap-southeast-2.amazoncognito.com snap2snomed.security.clientId=v597lp3lk3ue2qtks5jb41la6 -snap2snomed.cors.allowedOriginPatterns=* -snap2snomed.security.adminGroup=AdminGroup \ No newline at end of file +spring.data.rest.max-page-size=10000 +spring.datasource.url=jdbc:mysql://localhost/snap2snomed?cachePrepStmts\=true&useServerPrepStmts\=false&rewriteBatchedStatements\=true&socketTimeout\=480000 +spring.datasource.username=snap2snomed +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_oQSXJHFz9 +snap2snomed.security.adminGroup=AdminGroup diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index dc2a128c..1d80f99e 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -9,6 +9,7 @@ springdoc.swagger-ui.disable-swagger-default-url=true spring.web.resources.add-mappings=false spring.mvc.throw-exception-if-no-handler-found=true server.servlet.encoding.force=true +spring.data.rest.max-page-size=10000 spring.jpa.properties.hibernate.jdbc.batch_size=100 spring.jpa.properties.hibernate.order_inserts=true ## TODO configure me for the OIDC Discovery endpoint or OAuth 2.0 Authorisation Server Metadata endpoint diff --git a/api/src/main/resources/db/migration/h2/V1__init.sql b/api/src/main/resources/db/migration/h2/V1__init.sql deleted file mode 100644 index d8389f9c..00000000 --- a/api/src/main/resources/db/migration/h2/V1__init.sql +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright © 2022 SNOMED International - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -create table if not exists imported_code (id bigint generated by default as identity, code varchar(50), display varchar(512), _index bigint not null, imported_codeset_id bigint not null, primary key (id)); -create table if not exists imported_codeset (id bigint generated by default as identity, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), name varchar(100), version varchar(30), primary key (id)); -create table if not exists map (id bigint generated by default as identity, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), map_version varchar(30), to_scope varchar(1024) not null, to_version varchar(60), project_id bigint not null, source_id bigint not null, primary key (id)); -create table if not exists map_row (id bigint generated by default as identity, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), no_map boolean not null, status integer not null, author_task_id bigint, "last_author_id" varchar(255), "last_reviewer_id" varchar(255), map_id bigint not null, review_task_id bigint, source_code_id bigint not null, primary key (id)); -create table if not exists map_row_target (id bigint generated by default as identity, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), flagged boolean not null, relationship integer not null, target_code varchar(18), target_display varchar(2048), row_id bigint not null, primary key (id)); -create table if not exists note (id bigint generated by default as identity, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), note_text varchar(256) not null, maprow_id bigint not null, "note_by_id" varchar(255) not null, primary key (id)); -create table if not exists project (id bigint generated by default as identity, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), description varchar(200), title varchar(100), primary key (id)); -create table if not exists project_guests (project_id bigint not null, guests_id varchar(255) not null, primary key (project_id, guests_id)); -create table if not exists project_members (project_id bigint not null, members_id varchar(255) not null, primary key (project_id, members_id)); -create table if not exists project_owners (project_id bigint not null, owners_id varchar(255) not null, primary key (project_id, owners_id)); -create table if not exists task (id bigint generated by default as identity, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), description varchar(60), type integer not null, "assignee_id" varchar(255), map_id bigint not null, primary key (id)); -create table if not exists "user" (id varchar(255) not null, created timestamp, created_by varchar(255), modified timestamp, modified_by varchar(255), email varchar(255), family_name varchar(100), given_name varchar(100), nickname varchar(100), primary key (id)); -alter table imported_code add constraint if not exists UK2kj7s8wg0qgrisyvkf6rtdpf3 unique (_index, imported_codeset_id); -alter table imported_code add constraint if not exists UK2rnm9qqto6h88nqeoas3so3g unique (code, imported_codeset_id); -alter table map_row add constraint if not exists UniqueMapAndSourceCode unique (map_id, source_code_id); -alter table imported_code add constraint if not exists FKbmqbof5iexq8mo6p6vw1uh5e7 foreign key (imported_codeset_id) references imported_codeset; -alter table map add constraint if not exists FK5a8ljc6xrj8w0xmlyr92mrw2t foreign key (project_id) references project; -alter table map add constraint if not exists FK9jdh2jk489y0b49o4k35ld4ch foreign key (source_id) references imported_codeset; -alter table map_row add constraint if not exists FK5sq41qusaerjw4l4gm9tudrkq foreign key (author_task_id) references task; -alter table map_row add constraint if not exists FKsufg1nb3gqe6k06sarrwdjiht foreign key ("last_author_id") references "user"; -alter table map_row add constraint if not exists FKas95ff8edlb36ql1ko1rertgp foreign key ("last_reviewer_id") references "user"; -alter table map_row add constraint if not exists FK93uu5g46v77a1uah6ck1enwwl foreign key (map_id) references map; -alter table map_row add constraint if not exists FKs7lcvu6u8r0w2hap6ve299iag foreign key (review_task_id) references task; -alter table map_row add constraint if not exists FK9hqcobqlpiqo60q1mg1ywhgra foreign key (source_code_id) references imported_code; -alter table map_row_target add constraint if not exists FK97xhy765d746ecdgtgy7ccki4 foreign key (row_id) references map_row; -alter table note add constraint if not exists FK4c6i16l2wjnbd4wf7cea5jh2u foreign key (maprow_id) references map_row; -alter table note add constraint if not exists FKs7yw5sgwd20bsdl2shhq21u1d foreign key ("note_by_id") references "user"; -alter table project_guests add constraint if not exists FK72xypqt6fthn93fr2grn2ncre foreign key (guests_id) references "user"; -alter table project_guests add constraint if not exists FK7bemis13nkyirufjjuw017pfh foreign key (project_id) references project; -alter table project_members add constraint if not exists FKsgthbwe2h7rtyme5msv3rvyi6 foreign key (members_id) references "user"; -alter table project_members add constraint if not exists FKi28gx2d4xrrhtrfnk12aef1e4 foreign key (project_id) references project; -alter table project_owners add constraint if not exists FK9nbt24endqpu1ximibb1mcwag foreign key (owners_id) references "user"; -alter table project_owners add constraint if not exists FKexkqjlfmh77jqhim0i33su5pl foreign key (project_id) references project; -alter table task add constraint if not exists FKlb5j5ow1845t8jxg555ums4th foreign key ("assignee_id") references "user"; -alter table task add constraint if not exists FKd6jex3bd7gmx27d5efexbyf8m foreign key (map_id) references map; diff --git a/api/src/main/resources/db/migration/h2/V4__envers.sql b/api/src/main/resources/db/migration/h2/V4__envers.sql deleted file mode 100644 index b10b3411..00000000 --- a/api/src/main/resources/db/migration/h2/V4__envers.sql +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright © 2022 SNOMED International - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -create table if not exists imported_codeset_aud -( - id bigint not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - modified timestamp, - modified_by varchar(255), - name varchar(255), - version varchar(255), - primary key (id, rev) -); -create table if not exists map_aud -( - id bigint not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - map_version varchar(255), - modified timestamp, - modified_by varchar(255), - to_scope varchar(255), - to_version varchar(255), - project_id bigint, - source_id bigint, - primary key (id, rev) -); -create table if not exists map_row_aud -( - id bigint not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - modified timestamp, - modified_by varchar(255), - no_map boolean default false, - status integer, - author_task_id bigint, - last_author_id varchar(255), - last_reviewer_id varchar(255), - map_id bigint, - review_task_id bigint, - source_code_id bigint, - primary key (id, rev) -); -create table if not exists map_row_target_aud -( - id bigint not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - flagged boolean, - modified timestamp, - modified_by varchar(255), - relationship integer, - target_code varchar(255), - target_display varchar(255), - row_id bigint, - primary key (id, rev) -); -create table if not exists note_aud -( - id bigint not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - modified timestamp, - modified_by varchar(255), - note_text varchar(255), - maprow_id bigint, - note_by_id varchar(255), - primary key (id, rev) -); -create table if not exists project_aud -( - id bigint not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - description varchar(255), - modified timestamp, - modified_by varchar(255), - title varchar(255), - primary key (id, rev) -); -create table if not exists project_guests_aud -( - rev integer not null, - project_id bigint not null, - guests_id varchar(255) not null, - revtype tinyint, - primary key (rev, project_id, guests_id) -); -create table if not exists project_members_aud -( - rev integer not null, - project_id bigint not null, - members_id varchar(255) not null, - revtype tinyint, - primary key (rev, project_id, members_id) -); -create table if not exists project_owners_aud -( - rev integer not null, - project_id bigint not null, - owners_id varchar(255) not null, - revtype tinyint, - primary key (rev, project_id, owners_id) -); -create table if not exists task_aud -( - id bigint not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - description varchar(255), - modified timestamp, - modified_by varchar(255), - type integer, - assignee_id varchar(255), - map_id bigint, - primary key (id, rev) -); -create table if not exists user_aud -( - id varchar(255) not null, - rev integer not null, - revtype tinyint, - created timestamp, - created_by varchar(255), - email varchar(255), - family_name varchar(255), - given_name varchar(255), - modified timestamp, - modified_by varchar(255), - nickname varchar(255), - primary key (id, rev) -); -alter table imported_codeset_aud - add constraint if not exists FKor5te4xivhobecfhum7tfr44t foreign key (rev) references revinfo (rev); -alter table map_aud - add constraint if not exists FKhklbxq5ob1un7rtgy62g41hqu foreign key (rev) references revinfo (rev); -alter table map_row_aud - add constraint if not exists FKcgl9ajracdgcy1y1se5f3wigl foreign key (rev) references revinfo (rev); -alter table map_row_target_aud - add constraint if not exists FKsrdptu7mo9a5vktdbjel9421v foreign key (rev) references revinfo (rev); -alter table note_aud - add constraint if not exists FKf4lnpja18lffbwr2fij7t2xrt foreign key (rev) references revinfo (rev); -alter table project_aud - add constraint if not exists FKpnojd25gxjyn8jg0mj5k96mcl foreign key (rev) references revinfo (rev); -alter table project_guests_aud - add constraint if not exists FKt2p1n1jrykm0b1h3xautklv55 foreign key (rev) references revinfo (rev); -alter table project_members_aud - add constraint if not exists FKc9p9ut0twhogsql3m2t0jdxir foreign key (rev) references revinfo (rev); -alter table project_owners_aud - add constraint if not exists FK6af8kc8w1ytoal4lfoa7144r9 foreign key (rev) references revinfo (rev); -alter table task_aud - add constraint if not exists FKaerb34sjraiw4vjh4oh46rb71 foreign key (rev) references revinfo (rev); -alter table user_aud - add constraint if not exists FK89ntto9kobwahrwxbne2nqcnr foreign key (rev) references revinfo (rev); \ No newline at end of file diff --git a/api/src/main/resources/db/migration/h2/V8__create_source_additional_columns.sql b/api/src/main/resources/db/migration/h2/V8__create_source_additional_columns.sql deleted file mode 100644 index b2e029da..00000000 --- a/api/src/main/resources/db/migration/h2/V8__create_source_additional_columns.sql +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2022 SNOMED International - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -drop table if exists imported_code_additional_columns cascade; -create table imported_code_additional_columns (imported_code_id bigint not null, value varchar(255), collection_order integer not null, primary key (imported_code_id, collection_order)); -create table imported_codeset_additional_columns (imported_codeset_id bigint not null, name varchar(255), type integer, collection_order integer not null, primary key (imported_codeset_id, collection_order)); - -alter table imported_code_additional_columns add constraint FK5vpm8qo68l2bkocmeoufnfiqx foreign key (imported_code_id) references imported_code; -alter table imported_codeset_additional_columns add constraint FK5vpm8qo68l2bkocmeoufnfiqy foreign key (imported_codeset_id) references imported_codeset; - -create table imported_codeset_additional_columns_aud (rev integer not null, revtype tinyint not null, imported_codeset_id bigint not null, collection_order integer not null, name varchar(255), type integer, primary key (rev, revtype, imported_codeset_id, collection_order)); - -alter table imported_codeset_additional_columns_aud add constraint if not exists FK89ntto9kobwahrwxbne2nqcny foreign key (rev) references revinfo; - diff --git a/api/src/main/resources/db/migration/mysql/V10__create_map_row_target_tags_constraints_idx.sql b/api/src/main/resources/db/migration/mysql/V10__create_map_row_target_tags_constraints_idx.sql new file mode 100644 index 00000000..558e2fd8 --- /dev/null +++ b/api/src/main/resources/db/migration/mysql/V10__create_map_row_target_tags_constraints_idx.sql @@ -0,0 +1,20 @@ +/* + * Copyright © 2022 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +alter table map_row_target_tags_aud add constraint FK3v475gipqy1gpqov5ssf816pg foreign key (rev) references revinfo (rev); +alter table map_row_target_tags add constraint FKf8qe9o6u78cpkogwk5x0bsyui foreign key (map_row_target_id) references map_row_target (id); + +alter table map_row_target_tags add index map_row_target_id (map_row_target_id); diff --git a/api/src/main/resources/db/migration/h2/V5__increase_column_size.sql b/api/src/main/resources/db/migration/mysql/V11__create_map_row_target_tags_unique_idx.sql similarity index 85% rename from api/src/main/resources/db/migration/h2/V5__increase_column_size.sql rename to api/src/main/resources/db/migration/mysql/V11__create_map_row_target_tags_unique_idx.sql index 16d43749..d71e2797 100644 --- a/api/src/main/resources/db/migration/h2/V5__increase_column_size.sql +++ b/api/src/main/resources/db/migration/mysql/V11__create_map_row_target_tags_unique_idx.sql @@ -14,5 +14,4 @@ * limitations under the License. */ -alter table imported_code alter column display VARCHAR(2048); - +alter table map_row_target_tags add constraint map_row_target_tag_unique unique (map_row_target_id, tags); diff --git a/api/src/main/resources/db/migration/mysql/V12__map_row_target_tags_cascade_deletes.sql b/api/src/main/resources/db/migration/mysql/V12__map_row_target_tags_cascade_deletes.sql new file mode 100644 index 00000000..26a1d460 --- /dev/null +++ b/api/src/main/resources/db/migration/mysql/V12__map_row_target_tags_cascade_deletes.sql @@ -0,0 +1,18 @@ +/* + * Copyright © 2022 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +alter table map_row_target_tags drop constraint FKf8qe9o6u78cpkogwk5x0bsyui; +alter table map_row_target_tags add constraint FKf8qe9o6u78cpkogwk5x0bsyui foreign key (map_row_target_id) references map_row_target (id) on delete cascade; diff --git a/api/src/main/resources/db/migration/mysql/V13__dual_mapping_mode.sql b/api/src/main/resources/db/migration/mysql/V13__dual_mapping_mode.sql new file mode 100644 index 00000000..61524ab6 --- /dev/null +++ b/api/src/main/resources/db/migration/mysql/V13__dual_mapping_mode.sql @@ -0,0 +1,53 @@ +/* + * Copyright © 2023 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +ALTER TABLE project ADD dual_map_mode BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE project_aud ADD dual_map_mode BOOLEAN; + +ALTER TABLE map_row DROP FOREIGN KEY FK93uu5g46v77a1uah6ck1enwwl; +ALTER TABLE map_row DROP CONSTRAINT UniqueMapAndSourceCode; +ALTER TABLE map_row ADD CONSTRAINT FK93uu5g46v77a1uah6ck1enwwl FOREIGN KEY (map_id) REFERENCES map (id); + +ALTER TABLE map_row ADD blind_map_flag BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE map_row_aud ADD blind_map_flag BOOLEAN; + +ALTER TABLE map_row ADD reconcile_task_id BIGINT; +ALTER TABLE map_row_aud ADD reconcile_task_id BIGINT; + +ALTER TABLE map_row ADD INDEX reconcile_task_idx (reconcile_task_id); + +/* + A union is created as a view as the full query cannot be represented by JPAQuery or BlazeJPAQuery. + + JPAQuery does not support unions. + + BlazeJPAQuery claims to support union but it doesn't support the call of a constructor from the second select of a union + .. from the blaze manual .. "The SELECT clause can be used to specify projections that should be returned by a query. + Blaze Persistence completely aligns with JPQL regarding the support of the SELECT clause, except for constructor expressions." + The workaround "selectNew" only works for CriteriaBuilder, not + QueryDSL and then "selectNew" cannot be called within a union https://github.com/Blazebit/blaze-persistence/issues/565" +*/ +CREATE OR REPLACE VIEW map_view AS + SELECT UUID() as 'id', id AS map_row_id, status, blind_map_flag, null as sibling_row_author_task_id + FROM map_row + WHERE map_row.blind_map_flag = false +UNION + SELECT UUID() as 'id', mr1.id AS map_row_id, (CASE WHEN mr1.status != mr2.status THEN '1' ELSE mr1.status END), mr1.blind_map_flag, + mr2.author_task_id + FROM map_row mr1, map_row mr2 + WHERE mr1.source_code_id = mr2.source_code_id + AND mr1.id < mr2.id + AND mr1.blind_map_flag = true; \ No newline at end of file diff --git a/api/src/main/resources/db/migration/mysql/V14__dual_mapping_mode_part_2.sql b/api/src/main/resources/db/migration/mysql/V14__dual_mapping_mode_part_2.sql new file mode 100644 index 00000000..a6b78cea --- /dev/null +++ b/api/src/main/resources/db/migration/mysql/V14__dual_mapping_mode_part_2.sql @@ -0,0 +1,28 @@ +/* + * Copyright © 2023 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +CREATE OR REPLACE VIEW map_view AS + SELECT UUID() as 'id', id AS map_row_id, status, blind_map_flag, null as sibling_row_author_task_id + FROM map_row + WHERE map_row.blind_map_flag = false +UNION + SELECT UUID() as 'id', mr1.id AS map_row_id, (CASE WHEN mr1.status != mr2.status THEN '1' ELSE mr1.status END), mr1.blind_map_flag, + mr2.author_task_id + FROM map_row mr1, map_row mr2 + WHERE mr1.source_code_id = mr2.source_code_id + AND mr1.id < mr2.id + and mr1.map_id = mr2.map_id + AND mr1.blind_map_flag = true; \ No newline at end of file diff --git a/api/src/main/resources/db/migration/h2/V6__create_maprow_indices.sql b/api/src/main/resources/db/migration/mysql/V15__dual_mapping_mode_note_category.sql similarity index 75% rename from api/src/main/resources/db/migration/h2/V6__create_maprow_indices.sql rename to api/src/main/resources/db/migration/mysql/V15__dual_mapping_mode_note_category.sql index 0a1e78af..e2ee1178 100644 --- a/api/src/main/resources/db/migration/h2/V6__create_maprow_indices.sql +++ b/api/src/main/resources/db/migration/mysql/V15__dual_mapping_mode_note_category.sql @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2023 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,6 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +ALTER TABLE note +ADD COLUMN (category integer not null); + +ALTER TABLE note_aud +ADD COLUMN (category integer); -CREATE INDEX IF NOT EXISTS author_task_idx ON map_row(author_task_id); -CREATE INDEX IF NOT EXISTS review_task_idx ON map_row(review_task_id); \ No newline at end of file diff --git a/api/src/main/resources/db/migration/mysql/V16__map_row_target_last_author.sql b/api/src/main/resources/db/migration/mysql/V16__map_row_target_last_author.sql new file mode 100644 index 00000000..7e466de5 --- /dev/null +++ b/api/src/main/resources/db/migration/mysql/V16__map_row_target_last_author.sql @@ -0,0 +1,21 @@ +/* + * Copyright © 2023 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +alter table map_row_target add last_author_id varchar(255); + +alter table map_row_target_aud add last_author_id varchar(255); + +update map_row_target set last_author_id = modified_by; \ No newline at end of file diff --git a/api/src/main/resources/db/migration/h2/V2__nomap-default.sql b/api/src/main/resources/db/migration/mysql/V17__dual_mapping_category_default.sql similarity index 84% rename from api/src/main/resources/db/migration/h2/V2__nomap-default.sql rename to api/src/main/resources/db/migration/mysql/V17__dual_mapping_category_default.sql index 698cc976..c8818cab 100644 --- a/api/src/main/resources/db/migration/h2/V2__nomap-default.sql +++ b/api/src/main/resources/db/migration/mysql/V17__dual_mapping_category_default.sql @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2023 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,5 +14,5 @@ * limitations under the License. */ -alter table map_row - alter column no_map set default false; +ALTER TABLE note +ALTER COLUMN category SET DEFAULT 0; \ No newline at end of file diff --git a/api/src/main/resources/db/migration/mysql/V18__add_imported_codeset_metadata.sql b/api/src/main/resources/db/migration/mysql/V18__add_imported_codeset_metadata.sql new file mode 100644 index 00000000..1f9df11a --- /dev/null +++ b/api/src/main/resources/db/migration/mysql/V18__add_imported_codeset_metadata.sql @@ -0,0 +1,21 @@ +/* + * Copyright © 2023 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +alter table imported_codeset add system_uri VARCHAR(255); +alter table imported_codeset add valueset_uri VARCHAR(255); + +alter table imported_codeset_aud add system_uri VARCHAR(255); +alter table imported_codeset_aud add valueset_uri VARCHAR(255); diff --git a/api/src/main/resources/db/migration/mysql/V9__create_map_row_target_tags.sql b/api/src/main/resources/db/migration/mysql/V9__create_map_row_target_tags.sql new file mode 100644 index 00000000..4a7090c2 --- /dev/null +++ b/api/src/main/resources/db/migration/mysql/V9__create_map_row_target_tags.sql @@ -0,0 +1,18 @@ +/* + * Copyright © 2022 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +create table map_row_target_tags_aud (rev integer not null, map_row_target_id bigint not null, tags varchar(255) not null, revtype tinyint, primary key (rev, map_row_target_id, tags)) engine=InnoDB; +create table map_row_target_tags (map_row_target_id bigint not null, tags varchar(255)) engine=InnoDB; diff --git a/api/src/main/resources/db/migration/h2/V4_1_0__accepted_term_version.sql b/api/src/main/resources/local-init.sql similarity index 72% rename from api/src/main/resources/db/migration/h2/V4_1_0__accepted_term_version.sql rename to api/src/main/resources/local-init.sql index ea0b7c12..4893d807 100644 --- a/api/src/main/resources/db/migration/h2/V4_1_0__accepted_term_version.sql +++ b/api/src/main/resources/local-init.sql @@ -14,7 +14,11 @@ * limitations under the License. */ -alter table "user" add accepted_terms_version VARCHAR(255); -alter table map_row_target_aud alter column target_display VARCHAR(2048); -alter table user_aud add accepted_terms_version VARCHAR(255); +/* Create the database. */ +CREATE DATABASE snap2snomed; +/* Create a local user. */ +CREATE USER snap2snomed@localhost; + +/* Grant all privileges to the local user. */ +GRANT ALL PRIVILEGES ON snap2snomed.* TO snap2snomed@localhost; diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/Snap2snomedRestClient.java b/api/src/test/java/org/snomed/snap2snomed/integration/Snap2snomedRestClient.java index e2d444a4..0dac302f 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/Snap2snomedRestClient.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/Snap2snomedRestClient.java @@ -54,6 +54,7 @@ import org.snomed.snap2snomed.model.User; import org.snomed.snap2snomed.model.enumeration.MapStatus; import org.snomed.snap2snomed.model.enumeration.MappingRelationship; +import org.snomed.snap2snomed.model.enumeration.NoteCategory; import org.snomed.snap2snomed.model.enumeration.TaskType; import org.snomed.snap2snomed.security.AuthenticationFacade; import org.springframework.beans.factory.annotation.Autowired; @@ -660,6 +661,7 @@ public long createTarget(String user, long mapRowId, String targetCode, String t map.put("targetDisplay", targetDisplay); map.put("relationship", relationship); map.put("flagged", flagged); + map.put("blindMapFlag", false); final ValidatableResponse response = givenUser(user) .body(objectMapper.writeValueAsString(map)) @@ -673,14 +675,16 @@ public long createTarget(String user, long mapRowId, String targetCode, String t } } - public void updateTarget(String user, long targetId, String targetCode, String targetDisplay, MappingRelationship relationship, - boolean flagged, int expectedStatusCode) throws JsonProcessingException { + public void updateTarget(String user, long targetId, String targetCode, String targetDisplay, + MappingRelationship relationship, boolean flagged, final Set tags, + int expectedStatusCode) throws JsonProcessingException { final java.util.Map map = new HashMap<>(); map.put("targetCode", targetCode); map.put("targetDisplay", targetDisplay); map.put("relationship", relationship); map.put("flagged", flagged); + map.put("tags", tags); final ValidatableResponse response = givenUser(user) .body(objectMapper.writeValueAsString(map)) @@ -733,6 +737,7 @@ public String createProjectJson(String title, String description, Set ow final java.util.Map map = new HashMap<>(); map.put("title", title); map.put("description", description); + map.put("dualMapMode", false); if (owners != null) { map.put("owners", owners.stream().map(o -> "/users/" + o).collect(Collectors.toList())); } @@ -774,6 +779,7 @@ public String createNoteJson(Long mapRowId, String userId, String noteText) map.put("mapRow", "/mapRows/" + mapRowId); map.put("noteBy", "/users/" + userId); map.put("noteText", noteText); + map.put("category", NoteCategory.USER); return objectMapper.writeValueAsString(map); } diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/controller/ImportedMappingRestControllerIT.java b/api/src/test/java/org/snomed/snap2snomed/integration/controller/ImportedMappingRestControllerIT.java index 6f515dad..d7e16e7b 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/controller/ImportedMappingRestControllerIT.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/controller/ImportedMappingRestControllerIT.java @@ -218,7 +218,7 @@ public void failCreateEntityInvalidCsv() throws Exception { 400, null, DEFAULT_TEST_USER_SUBJECT); restClient.expectCreateImportedMapFail(0, 2, 3, 4, -1, -1, true, ",", new ClassPathResource("AAA_invalid_csv_doublequotes.csv").getFile(), "text/csv", mapId, - 400, null, DEFAULT_TEST_USER_SUBJECT); + 500, null, DEFAULT_TEST_USER_SUBJECT); } /** diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/controller/MapViewControllerIT.java b/api/src/test/java/org/snomed/snap2snomed/integration/controller/MapViewControllerIT.java index 0deabfb2..b9009d1f 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/controller/MapViewControllerIT.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/controller/MapViewControllerIT.java @@ -27,17 +27,13 @@ import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.io.Files; -import io.restassured.http.ContentType; -import io.restassured.response.ValidatableResponse; -import io.restassured.specification.RequestSpecification; import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.util.Iterator; import java.util.Set; + import org.apache.commons.lang3.tuple.Pair; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; @@ -56,6 +52,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.ClassPathResource; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.io.Files; + +import ca.uhn.fhir.context.FhirContext; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import io.restassured.specification.RequestSpecification; + @TestInstance(Lifecycle.PER_CLASS) public class MapViewControllerIT extends IntegrationTestBase { @@ -67,8 +71,8 @@ public class MapViewControllerIT extends IntegrationTestBase { private long taskId, task2Id; - private String user = "another-test-user"; - private String user2 = "yet-another-test-user"; + private final String user = "another-test-user"; + private final String user2 = "yet-another-test-user"; private long projectId, mapId; private long codesetId; @@ -144,16 +148,16 @@ public void shouldGetViewNoTargets() throws Exception { .body("content[20].targetDisplay", is(nullValue())) .body("content[20].relationship", is(nullValue())) .body("content[20].status", is(MapStatus.UNMAPPED.name())) - .body("content[20].assignedAuthor.id", is(user)) + .body("content[20].assignedAuthor[0].id", is(user)) .body("content[20].assignedReviewer.id", is(user2)); } private ValidatableResponse getMapView(int size, int expectedStatusCode, String sortColumn, Pair... qs) { - RequestSpecification request = restClient.givenUser(user) + final RequestSpecification request = restClient.givenUser(user) .queryParam("size", 100) .queryParam("sort", sortColumn); - for (Pair item : qs) { + for (final Pair item : qs) { request.queryParam(item.getLeft(), item.getRight()); } @@ -163,11 +167,11 @@ private ValidatableResponse getMapView(int size, int expectedStatusCode, String } private ValidatableResponse getTaskView(long taskId, int size, int expectedStatusCode, String sortColumn, Pair... qs) { - RequestSpecification request = restClient.givenUser(user) + final RequestSpecification request = restClient.givenUser(user) .queryParam("size", 100) .queryParam("sort", sortColumn); - for (Pair item : qs) { + for (final Pair item : qs) { request.queryParam(item.getLeft(), item.getRight()); } @@ -615,14 +619,14 @@ public void badExportRequestUnknownMapId() throws Exception { @Test public void testExportCsv() throws Exception { - byte[] result = exportMapViewFile(MapViewRestController.TEXT_CSV); + final byte[] result = exportMapViewFile(MapViewRestController.TEXT_CSV); assertCsvContent(result); } private byte[] exportMapViewFile(String accept, Pair... qs) { - RequestSpecification request = restClient.givenUser(user, "application/json", accept); + final RequestSpecification request = restClient.givenUser(user, "application/json", accept); - for (Pair item : qs) { + for (final Pair item : qs) { request.queryParam(item.getLeft(), item.getRight()); } return request.get("/mapView/" + mapId) @@ -634,22 +638,46 @@ private byte[] exportMapViewFile(String accept, Pair... qs) { @Test public void testExportTsv() throws Exception { - byte[] result = exportMapViewFile(MapViewRestController.TEXT_TSV); + final byte[] result = exportMapViewFile(MapViewRestController.TEXT_TSV); assertTsvContent(result); } @Test public void testExportXlsx() throws Exception { - byte[] result = exportMapViewFile(MapViewRestController.APPLICATION_XSLX); + final byte[] result = exportMapViewFile(MapViewRestController.APPLICATION_XSLX); assertXlsxContent(result); } + // @Test + // public void testExportXlsxExtended() throws Exception { + // byte[] result = exportMapViewFile(MapViewRestController.APPLICATION_XSLX, Pair.of("extraColumns", "notes"), Pair.of("extraColumns", "author"), Pair.of("extraColumns", "reviewer")); + // assertXlsxContent(result); + // } + + // @Test + // public void testExportTsvExtended() throws Exception { + // byte[] result = exportMapViewFile(MapViewRestController.TEXT_TSV, Pair.of("extraColumns", "notes"), Pair.of("extraColumns", "author"), Pair.of("extraColumns", "reviewer")); + // assertTsvContent(result); + // } + + // @Test + // public void testExportCsvExtended() throws Exception { + // byte[] result = exportMapViewFile(MapViewRestController.TEXT_CSV, Pair.of("extraColumns", "notes"), Pair.of("extraColumns", "author"), Pair.of("extraColumns", "reviewer")); + // assertCsvContent(result); + // } + + @Test + public void testExportFhirJson() throws Exception { + final byte[] result = exportMapViewFile(MapViewRestController.FHIR_JSON); + assertFhirJsonContent(result); + } + @Test public void testExportIgnoresSortAndSizeParametersCsv() throws Exception { // Assert that adding size/sort parameters doesn't affect export - byte[] result = exportMapViewFile(MapViewRestController.TEXT_CSV, Pair.of("size", 5), Pair.of("sort", "sourceDisplay")); + final byte[] result = exportMapViewFile(MapViewRestController.TEXT_CSV, Pair.of("size", 5), Pair.of("sort", "sourceDisplay")); assertCsvContent(result); } @@ -658,7 +686,7 @@ public void testExportIgnoresSortAndSizeParametersCsv() throws Exception { @Test public void testExportIgnoresSortAndSizeParametersTsv() throws Exception { // Assert that adding size/sort parameters doesn't affect export - byte[] result = exportMapViewFile(MapViewRestController.TEXT_TSV, Pair.of("size", 5), Pair.of("sort", "sourceDisplay")); + final byte[] result = exportMapViewFile(MapViewRestController.TEXT_TSV, Pair.of("size", 5), Pair.of("sort", "sourceDisplay")); assertTsvContent(result); } @@ -666,7 +694,7 @@ public void testExportIgnoresSortAndSizeParametersTsv() throws Exception { @Test public void testExportIgnoresSortAndSizeParametersXlsx() throws Exception { // Assert that adding size/sort parameters doesn't affect export - byte[] result = exportMapViewFile(MapViewRestController.APPLICATION_XSLX, Pair.of("size", 5), Pair.of("sort", "sourceDisplay")); + final byte[] result = exportMapViewFile(MapViewRestController.APPLICATION_XSLX, Pair.of("size", 5), Pair.of("sort", "sourceDisplay")); assertXlsxContent(result); } @@ -675,30 +703,41 @@ private void assertTsvContent(byte[] result) throws IOException { .isEqualTo(Files.toByteArray(new ClassPathResource("test_export.tsv").getFile())); } - private void assertCsvContent(byte[] result) throws IOException { assertThat(result).isEqualTo(Files.toByteArray(new ClassPathResource("test_export.csv").getFile())); } + private void assertFhirJsonContent(byte[] result) throws IOException { + final FhirContext ctx = FhirContext.forR4(); + final String r = new String(result); + final String x = new String(Files.toByteArray(new ClassPathResource("test_export.json").getFile())); + final int idx_r = r.indexOf("\"date\":"); + final int idx_x = x.indexOf("\"date\":"); + final int skip = 35; + + assertThat(r.substring(0, idx_r)).isEqualTo(x.substring(0, idx_x)); + assertThat(r.substring(idx_r + skip)).isEqualTo(x.substring(idx_x + skip)); + } + private void assertXlsxContent(byte[] result) throws IOException, FileNotFoundException { try (ByteArrayInputStream export = new ByteArrayInputStream(result); Workbook exportedWorkbook = new XSSFWorkbook(export);) { - Iterator exportedIterator = exportedWorkbook.getSheetAt(0).iterator(); + final Iterator exportedIterator = exportedWorkbook.getSheetAt(0).iterator(); try (FileInputStream expected = new FileInputStream(new ClassPathResource("test_export.xlsx").getFile()); Workbook expectedWorkbook = new XSSFWorkbook(expected);) { - Iterator expectedIterator = expectedWorkbook.getSheetAt(0).iterator(); + final Iterator expectedIterator = expectedWorkbook.getSheetAt(0).iterator(); while (exportedIterator.hasNext()) { - Row exportedRow = exportedIterator.next(); - Row expectedRow = expectedIterator.next(); + final Row exportedRow = exportedIterator.next(); + final Row expectedRow = expectedIterator.next(); - Iterator exportedCellIterator = exportedRow.iterator(); - Iterator expectedCellIterator = expectedRow.iterator(); + final Iterator exportedCellIterator = exportedRow.iterator(); + final Iterator expectedCellIterator = expectedRow.iterator(); while (exportedCellIterator.hasNext()) { - Cell exportedCell = exportedCellIterator.next(); - Cell expectedCell = expectedCellIterator.next(); + final Cell exportedCell = exportedCellIterator.next(); + final Cell expectedCell = expectedCellIterator.next(); switch (exportedCell.getCellType()) { case NUMERIC: @@ -730,14 +769,14 @@ private void assertXlsxContent(byte[] result) throws IOException, FileNotFoundEx /** Admin access tests where admin user is not owner **/ private ValidatableResponse getMapViewAsAdmin(int size, int expectedStatusCode, String sortColumn, Pair... qs) { - RequestSpecification request = restClient.givenUserWithGroup(DEFAULT_TEST_ADMIN_USER_SUBJECT, + final RequestSpecification request = restClient.givenUserWithGroup(DEFAULT_TEST_ADMIN_USER_SUBJECT, ContentType.JSON.getContentTypeStrings()[0], ContentType.JSON, config.getSecurity().getAdminGroup()) .queryParam("size", 100) .queryParam("sort", sortColumn); - for (Pair item : qs) { + for (final Pair item : qs) { request.queryParam(item.getLeft(), item.getRight()); } @@ -747,14 +786,14 @@ private ValidatableResponse getMapViewAsAdmin(int size, int expectedStatusCode, } private ValidatableResponse getTaskViewAsAdmin(long taskId, int size, int expectedStatusCode, String sortColumn, Pair... qs) { - RequestSpecification request = restClient.givenUserWithGroup(DEFAULT_TEST_ADMIN_USER_SUBJECT, + final RequestSpecification request = restClient.givenUserWithGroup(DEFAULT_TEST_ADMIN_USER_SUBJECT, ContentType.JSON.getContentTypeStrings()[0], ContentType.JSON, config.getSecurity().getAdminGroup()) .queryParam("size", 100) .queryParam("sort", sortColumn); - for (Pair item : qs) { + for (final Pair item : qs) { request.queryParam(item.getLeft(), item.getRight()); } @@ -776,7 +815,7 @@ public void shouldGetViewAsAdmin() throws Exception { .body("content[20].targetDisplay", is(nullValue())) .body("content[20].relationship", is(nullValue())) .body("content[20].status", is(MapStatus.UNMAPPED.name())) - .body("content[20].assignedAuthor.id", is(user)) + .body("content[20].assignedAuthor[0].id", is(user)) .body("content[20].assignedReviewer.id", is(user2)); } diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/controller/MappingControllerIT.java b/api/src/test/java/org/snomed/snap2snomed/integration/controller/MappingControllerIT.java index 1f39721e..a43a022c 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/controller/MappingControllerIT.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/controller/MappingControllerIT.java @@ -61,6 +61,7 @@ @Slf4j public class MappingControllerIT extends IntegrationTestBase { + public static final String TARGET_OUT_OF_SCOPE_TAG = "target-out-of-scope"; @Autowired ObjectMapper objectMapper; @@ -140,8 +141,13 @@ protected void beforeEachTest() throws IOException { @Test public void failNoMapAndStatusChangeAllRows() throws Exception { + MappingDto nomapDto = MappingDto.builder().noMap(true).status(MapStatus.UNMAPPED).build(); - expectFail("/updateMapping/map/" + mapId, nomapDto, 400, + MappingUpdateDto mappingUpdate = new MappingUpdateDto(); + List mappingDetails = new ArrayList(); + mappingDetails.add(MappingDetails.builder().mappingUpdate(nomapDto).build()); + mappingUpdate.setMappingDetails(mappingDetails); + expectFail("/updateMapping/map/" + mapId, mappingUpdate, 400, "Invalid combination of changes. Clear/set 'no map' and clearing targets must be done independently of any other changes"); expectFail("/updateMapping/task/" + taskId, nomapDto, 400, "Invalid combination of changes. Clear/set 'no map' and clearing targets must be done independently of any other changes"); @@ -167,7 +173,11 @@ public void failNoMapAndStatusChangeSelectedRows() throws Exception { @Test public void shouldBulkNoMapAllRows() throws Exception { MappingDto nomapDto = MappingDto.builder().noMap(true).build(); - checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, nomapDto, 35, 34); + MappingUpdateDto mappingUpdate = new MappingUpdateDto(); + List mappingDetails = new ArrayList(); + mappingDetails.add(MappingDetails.builder().mappingUpdate(nomapDto).build()); + mappingUpdate.setMappingDetails(mappingDetails); + checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, mappingUpdate, 35, 34); checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/task/" + taskId, nomapDto, 17, 16); } @@ -188,7 +198,11 @@ public void shouldBulkNoMapSelectedRows() throws Exception { @Test public void shouldBulkChangeStatusAll() throws Exception { MappingDto nomapDto = MappingDto.builder().status(MapStatus.REJECTED).build(); - checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, nomapDto, 35, 3); + MappingUpdateDto mappingUpdate = new MappingUpdateDto(); + List mappingDetails = new ArrayList(); + mappingDetails.add(MappingDetails.builder().mappingUpdate(nomapDto).build()); + mappingUpdate.setMappingDetails(mappingDetails); + checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, mappingUpdate, 35, 3); // 3 updates - 1st row has two maprowtargets and there is an inreview status for row 11 checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/task/" + taskId, nomapDto, 18, 0); checkRowCounts(user, "/updateMapping/task/" + task2Id, nomapDto, 5, 0); @@ -237,7 +251,11 @@ public void shouldBulkChangeStatusSelection() throws Exception { @Test public void shouldClearTargetsForMap() throws Exception { MappingDto nomapDto = MappingDto.builder().clearTarget(true).build(); - checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, nomapDto, 35, 9); + MappingUpdateDto mappingUpdate = new MappingUpdateDto(); + List mappingDetails = new ArrayList(); + mappingDetails.add(MappingDetails.builder().mappingUpdate(nomapDto).build()); + mappingUpdate.setMappingDetails(mappingDetails); + checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, mappingUpdate, 35, 9); } @@ -277,7 +295,11 @@ private List getMapViews() { @Test public void shouldChangeRelationshipForMap() throws Exception { MappingDto nomapDto = MappingDto.builder().relationship(MappingRelationship.TARGET_NARROWER).build(); - checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, nomapDto, 35, 9); + MappingUpdateDto mappingUpdate = new MappingUpdateDto(); + List mappingDetails = new ArrayList(); + mappingDetails.add(MappingDetails.builder().mappingUpdate(nomapDto).build()); + mappingUpdate.setMappingDetails(mappingDetails); + checkRowCounts(DEFAULT_TEST_USER_SUBJECT, "/updateMapping/map/" + mapId, mappingUpdate, 35, 9); checkRelationships( getMapViews().stream().filter(mv -> mv.getTargetId() != null && mv.getSourceIndex() != 11) .collect(Collectors.toList()), @@ -410,7 +432,7 @@ public void failChangeStatusToUnmapped() throws Exception { mappingUpdate.setMappingDetails(mappingDetails); expectFail("/updateMapping", mappingUpdate, 400, expectedDetail); - expectFail("/updateMapping/map/" + mapId, mappingDto, 400, expectedDetail); + expectFail("/updateMapping/map/" + mapId, mappingUpdate, 400, expectedDetail); expectFail("/updateMapping/task/" + taskId, mappingDto, 400, expectedDetail); } @@ -606,7 +628,7 @@ public void shouldCreateNewMappingVersionDifferentSource() throws Exception { assertNull(dto.assignedReviewer); assertNull(dto.lastAuthor); assertNull(dto.lastReviewer); - assertFalse(dto.flagged); + assertFalse(dto.containsTargetTag(TARGET_OUT_OF_SCOPE_TAG)); } } else { List originalMapViews = originalMapViewCache.get(code); @@ -702,7 +724,7 @@ public void shouldCreateNewMappingVersionSameSource() throws Exception { MapViewDto originalRow = originalMap.get(i); MapViewDto newRow = newMap.get(i); - if (originalRow.flagged) { + if (originalRow.containsTargetTag(TARGET_OUT_OF_SCOPE_TAG)) { originalHasFlagged = true; } @@ -756,7 +778,8 @@ private void compareClonedMapRow(int i, String originalDisplay, String newDispla assertEquals(originalRow.lastReviewer, newRow.lastReviewer, "Last reviewer be equal - row " + i); boolean expectToBeFlagged = newRow.targetCode != null && !newRow.targetCode.trim().isEmpty() && !isValidSctId(newRow.targetCode, RF2SchemaConstants.PartionIdentifier.CONCEPT); - assertEquals(expectToBeFlagged, newRow.flagged, "Flagged should be " + expectToBeFlagged + " - row " + i); + assertEquals(expectToBeFlagged, newRow.containsTargetTag(TARGET_OUT_OF_SCOPE_TAG), + "Flagged should be " + expectToBeFlagged + " - row " + i); } @Test @@ -879,7 +902,7 @@ static class MapViewDto { private Instant latestNote; - private UserDto assignedAuthor; + private List assignedAuthor; private UserDto assignedReviewer; @@ -888,6 +911,12 @@ static class MapViewDto { private UserDto lastReviewer; private boolean flagged; + + private Set targetTags; + + public boolean containsTargetTag(String tag) { + return targetTags != null && targetTags.contains(tag); + } } @Data diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/repository/ImportedCodeSetResourceIT.java b/api/src/test/java/org/snomed/snap2snomed/integration/repository/ImportedCodeSetResourceIT.java index 0fb98508..0eca0110 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/repository/ImportedCodeSetResourceIT.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/repository/ImportedCodeSetResourceIT.java @@ -235,7 +235,7 @@ public void failCreateEntityInvalidCsv() throws Exception { restClient.expectCreateImportedCodeSetFail("badAAA", "2", 0, 2, true, ",", new ClassPathResource("AAA_invalid_csv_mixeddelimiters.csv").getFile(), "text/csv", 400, null); restClient.expectCreateImportedCodeSetFail("badAAA", "2", 0, 2, true, ",", - new ClassPathResource("AAA_invalid_csv_doublequotes.csv").getFile(), "text/csv", 400, null); + new ClassPathResource("AAA_invalid_csv_doublequotes.csv").getFile(), "text/csv", 500, null); } /** diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/repository/MapRowTargetResourceIT.java b/api/src/test/java/org/snomed/snap2snomed/integration/repository/MapRowTargetResourceIT.java index 83116b26..2f05aaca 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/repository/MapRowTargetResourceIT.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/repository/MapRowTargetResourceIT.java @@ -22,22 +22,24 @@ import static org.hamcrest.Matchers.not; import static org.hamcrest.core.Is.is; -import io.restassured.specification.RequestSpecification; import java.io.IOException; -import java.util.List; +import java.util.Collections; import java.util.Set; + import org.apache.commons.lang3.tuple.Pair; -import org.junit.Before; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.snomed.snap2snomed.integration.IntegrationTestBase; -import org.snomed.snap2snomed.model.MapRowTarget; import org.snomed.snap2snomed.model.enumeration.MapStatus; import org.snomed.snap2snomed.model.enumeration.MappingRelationship; import org.snomed.snap2snomed.model.enumeration.TaskType; +import io.restassured.specification.RequestSpecification; + @TestInstance(Lifecycle.PER_CLASS) public class MapRowTargetResourceIT extends IntegrationTestBase { @@ -72,15 +74,34 @@ public void beforeEach() { @Test public void shouldGetByMapIdAndSourceCode() throws Exception { - long taskId = restClient.createTask(TaskType.AUTHOR, mapId, DEFAULT_TEST_USER_SUBJECT, "1"); + final Logger _log = LoggerFactory.getLogger(this.getClass()); + + System.err.flush(); + _log.error("XX----------------------------------------------------------------"); + System.err.flush(); + restClient.givenDefaultUser() + .queryParam("projection", "targetView") +// .queryParam("row.sourceCode.index", "1") + .queryParam("mapId", 1+mapId).log().all() + .get("/mapRowTargets") + .then().log().body().statusCode(200); + System.err.flush(); + _log.error("XX----------------------------------------------------------------"); + System.err.flush(); + + + final long taskId = restClient.createTask(TaskType.AUTHOR, mapId, DEFAULT_TEST_USER_SUBJECT, "1"); restClient.createTarget(DEFAULT_TEST_USER_SUBJECT, mapId, "map row code 1.", "target", "display", MappingRelationship.TARGET_EQUIVALENT, false); + System.err.flush(); + _log.error("------------------------------------------------------------------"); + System.err.flush(); restClient.givenDefaultUser() .queryParam("projection", "targetView") .queryParam("row.sourceCode.index", "1") - .queryParam("row.map.id", mapId).log().all() + .queryParam("mapId", mapId).log().all() .get("/mapRowTargets") .then().log().body().statusCode(200) .body("content", hasSize(1)) @@ -91,11 +112,14 @@ public void shouldGetByMapIdAndSourceCode() throws Exception { .body("content[0].targetDisplay", is("display")) .body("content[0].relationship", is("TARGET_EQUIVALENT")) .body("content[0].flagged", is(false)); + System.err.flush(); + _log.error("------------------------------------------------------------------"); + System.err.flush(); restClient.givenDefaultUser() .queryParam("projection", "targetView") .queryParam("row.sourceCode.index", "10") - .queryParam("row.map.id", mapId).log().all() + .queryParam("mapId", mapId).log().all() .get("/mapRowTargets") .then().log().body().statusCode(200) .body("content", hasSize(1)) @@ -106,19 +130,19 @@ public void shouldGetByMapIdAndSourceCode() throws Exception { @Test public void shouldSetLastAuthorEditMapRow() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 2."); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 2."); restClient.checkLastModified(mapRowId, "lastAuthor", null); restClient.checkLastModified(mapRowId, "lastReviewer", null); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, DEFAULT_TEST_USER_SUBJECT, "2"); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, DEFAULT_TEST_USER_SUBJECT, "2"); restClient.updateNoMapAndStatus(DEFAULT_TEST_USER_SUBJECT, mapRowId, true, MapStatus.MAPPED); restClient.checkLastModified(mapRowId, "lastAuthor", DEFAULT_TEST_USER_SUBJECT); restClient.checkLastModified(mapRowId, "lastReviewer", null); - long reviewTask = restClient.createTask(TaskType.REVIEW, mapId, MEMBER_TEST_USER, "2"); + final long reviewTask = restClient.createTask(TaskType.REVIEW, mapId, MEMBER_TEST_USER, "2"); restClient.updateStatus(MEMBER_TEST_USER, mapRowId, MapStatus.ACCEPTED); @@ -132,9 +156,9 @@ public void shouldSetLastAuthorEditMapRow() throws Exception { @Test public void failUpdateMapRowIllegalStateChange() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 3."); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 3."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, DEFAULT_TEST_USER_SUBJECT, "3"); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, DEFAULT_TEST_USER_SUBJECT, "3"); restClient.updateStatus(DEFAULT_TEST_USER_SUBJECT, mapRowId, MapStatus.MAPPED, 400, "Cannot change state from UNMAPPED for row with no mappings and 'no map' not set"); @@ -155,9 +179,9 @@ public void failUpdateMapRowIllegalStateChange() throws Exception { @Test public void failUpdateMapRowNotAuthor() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 3."); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 3."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "3"); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "3"); restClient.updateNoMapAndStatus(MEMBER_TEST_USER, mapRowId, true, MapStatus.DRAFT); @@ -172,31 +196,50 @@ public void failUpdateMapRowNotAuthor() throws Exception { @Test public void failUpdateMapRowTargetNoTask() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); - long targetId = restClient.createTarget(MEMBER_TEST_USER, mapRowId, "foo", "bar", MappingRelationship.TARGET_NARROWER, false, 201); - restClient.updateTarget(MEMBER_TEST_USER, targetId, "foo2", "bar2", MappingRelationship.TARGET_NARROWER, false, 200); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); + final long targetId = restClient.createTarget(MEMBER_TEST_USER, mapRowId, "foo", "bar", MappingRelationship.TARGET_NARROWER, false, 201); + restClient.updateTarget(MEMBER_TEST_USER, targetId, "foo2", "bar2", MappingRelationship.TARGET_NARROWER, false, Collections.emptySet(), 200); restClient.deleteTask(authorTask); - restClient.updateTarget(MEMBER_TEST_USER, targetId, "foo3", "bar3", MappingRelationship.TARGET_NARROWER, false, 403); + restClient.updateTarget(MEMBER_TEST_USER, targetId, "foo3", "bar3", MappingRelationship.TARGET_NARROWER, false, Collections.emptySet(), 403); } @Test public void shouldUpdateMapRowTargetFlagOwnerNoTask() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); - long targetId = restClient.createTarget(MEMBER_TEST_USER, mapRowId, "foo", "bar", MappingRelationship.TARGET_NARROWER, false, 201); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); + final long targetId = restClient.createTarget(MEMBER_TEST_USER, mapRowId, "foo", "bar", MappingRelationship.TARGET_NARROWER, false, 201); restClient.deleteTask(authorTask); restClient.updateTargetFlag(DEFAULT_TEST_USER_SUBJECT, targetId, true, 200); - restClient.updateTarget(MEMBER_TEST_USER, targetId, "foo2", "bar2", MappingRelationship.TARGET_NARROWER, false, 403); + restClient.updateTarget(MEMBER_TEST_USER, targetId, "foo2", "bar2", + MappingRelationship.TARGET_NARROWER, false, Collections.emptySet(), 403 + ); + } + + @Test + public void shouldUpdateAndRetrieveMapRowTargetTags() throws Exception { + final long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); + restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); + + final String targetCode = "foo"; + final String targetDisplay = "bar"; + final MappingRelationship relationship = MappingRelationship.TARGET_NARROWER; + final boolean flagged = false; + + final long targetId = restClient.createTarget(MEMBER_TEST_USER, mapRowId, targetCode, targetDisplay, + relationship, flagged, 201); + + restClient.updateTarget(MEMBER_TEST_USER, targetId, targetCode, targetDisplay, relationship, + flagged, Collections.singleton("some-tag"), 200); } @Test public void failUpdateMapRowTargetFlagNotOwnerNoTask() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); - long targetId = restClient.createTarget(MEMBER_TEST_USER, mapRowId, "foo", "bar", MappingRelationship.TARGET_NARROWER, false, 201); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); + final long targetId = restClient.createTarget(MEMBER_TEST_USER, mapRowId, "foo", "bar", MappingRelationship.TARGET_NARROWER, false, 201); restClient.deleteTask(authorTask); @@ -211,9 +254,9 @@ public void failUpdateMapRowTargetFlagNotOwnerNoTask() throws Exception { @Test public void failUpdateMapRowTargetNotAuthor() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); restClient.updateNoMapAndStatus(MEMBER_TEST_USER, mapRowId, true, MapStatus.DRAFT); @@ -231,10 +274,10 @@ public void failUpdateMapRowTargetNotAuthor() throws Exception { @Test public void failUpdateMapRowNotReviewer() throws Exception { - long mapRowId = restClient.getMapRowId(mapId, "map row code 5."); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 5."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "5"); - long reviewTask = restClient.createTask(TaskType.REVIEW, mapId, GUEST_TEST_USER, "5"); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "5"); + final long reviewTask = restClient.createTask(TaskType.REVIEW, mapId, GUEST_TEST_USER, "5"); restClient.updateNoMapAndStatus(MEMBER_TEST_USER, mapRowId, true, MapStatus.DRAFT); @@ -269,14 +312,14 @@ public void failUpdateMapRowNotReviewer() throws Exception { @Test public void shouldGetTargetRowsFilterByUnassigned() throws Exception { - int expectedRowCountTotal = 34; + final int expectedRowCountTotal = 34; - long map2Id = restClient.createMap("Testing Map Version 2", "http://snomed.info/sct/32506021000036107/version/20210531", + final long map2Id = restClient.createMap("Testing Map Version 2", "http://snomed.info/sct/32506021000036107/version/20210531", "http://map.test.toscope", projectId, codesetId); - long authorTask = restClient.createTask(TaskType.AUTHOR, map2Id, MEMBER_TEST_USER, "11-13"); - long reviewTask = restClient.createTask(TaskType.REVIEW, map2Id, GUEST_TEST_USER, "11-13"); - long authorTask2 = restClient.createTask(TaskType.AUTHOR, map2Id, GUEST_TEST_USER, "14-16"); + final long authorTask = restClient.createTask(TaskType.AUTHOR, map2Id, MEMBER_TEST_USER, "11-13"); + final long reviewTask = restClient.createTask(TaskType.REVIEW, map2Id, GUEST_TEST_USER, "11-13"); + final long authorTask2 = restClient.createTask(TaskType.AUTHOR, map2Id, GUEST_TEST_USER, "14-16"); validateMapViewRowCountForFilter(map2Id, expectedRowCountTotal - 6, Pair.of("assignedAuthor", "none")); @@ -304,11 +347,11 @@ public void shouldGetTargetRowsFilterByUnassigned() throws Exception { @Test public void testSearchByMapId() throws Exception { - long mapId = restClient.createMap("testSearchByMapId Map Version", "http://snomed.info/sct/32506021000036107/version/20210531", + final long mapId = restClient.createMap("testSearchByMapId Map Version", "http://snomed.info/sct/32506021000036107/version/20210531", "http://map.test.toscope", projectId, codesetId); - long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); - long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); + final long mapRowId = restClient.getMapRowId(mapId, "map row code 4."); + final long authorTask = restClient.createTask(TaskType.AUTHOR, mapId, MEMBER_TEST_USER, "4"); restClient.createTarget(MEMBER_TEST_USER, mapRowId, "foo", "bar2", MappingRelationship.TARGET_NARROWER, false, 201); restClient.deleteTask(authorTask); restClient.givenUser(MEMBER_TEST_USER).queryParam("mapId", mapId) @@ -324,7 +367,7 @@ public void testSearchByMapId() throws Exception { } private void validateMapViewRowCountForFilter(long mapId, int expectedRowCount, Pair... param) { - RequestSpecification requestSpecification = restClient.givenDefaultUser(); + final RequestSpecification requestSpecification = restClient.givenDefaultUser(); for (int j = 0; j < param.length; j++) { requestSpecification.queryParam(param[j].getLeft(), param[j].getRight()); } diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/repository/NoteResourceIT.java b/api/src/test/java/org/snomed/snap2snomed/integration/repository/NoteResourceIT.java index 6d77ed43..2f5ee065 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/repository/NoteResourceIT.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/repository/NoteResourceIT.java @@ -32,8 +32,12 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.greaterThan; + import static org.assertj.core.api.Assertions.assertThat; @@ -134,31 +138,36 @@ public void shouldShowLatestNoteInMapView() throws Exception { assertThat(parseTime(note3Modified)) .isBefore(parseTime(note4Modified)); + String note3ModifiedAndTransformed = note3Modified.replace("\"", ""); + note3ModifiedAndTransformed = note3ModifiedAndTransformed.substring(0, note3ModifiedAndTransformed.length()-1); + String note4ModifiedAndTransformed = note4Modified.replace("\"", ""); + note4ModifiedAndTransformed = note4ModifiedAndTransformed.substring(0, note4ModifiedAndTransformed.length()-1); + restClient.givenDefaultUser().get("/mapView/" + mapId + "?sort=sourceIndex").then().log() .ifValidationFails(LogDetail.BODY).statusCode(200) .body("content", hasSize(20)) .body("content[0].sourceCode", is("map1 row code 1.")) .body("content[1].sourceCode", is("map1 row code 2.")) - .body("content[1].latestNote", is(note3Modified.replace("\"", ""))) + .body("content[1].latestNote", allOf(startsWith(note3ModifiedAndTransformed), endsWith("Z"))) .body("content[2].sourceCode", is("map1 row code 3.")) - .body("content[2].latestNote", is(note4Modified.replace("\"", ""))); + .body("content[2].latestNote", allOf(startsWith(note4ModifiedAndTransformed), endsWith("Z"))); restClient.givenDefaultUser().get("/mapView/" + mapId + "?sort=latestNote&page=1").then().log() .ifValidationFails(LogDetail.BODY).statusCode(200) .body("content", hasSize(14)) .body("content[0].latestNote", nullValue()) .body("content[12].sourceCode", is("map1 row code 2.")) - .body("content[12].latestNote", is(note3Modified.replace("\"", ""))) + .body("content[12].latestNote", allOf(startsWith(note3ModifiedAndTransformed), endsWith("Z"))) .body("content[13].sourceCode", is("map1 row code 3.")) - .body("content[13].latestNote", is(note4Modified.replace("\"", ""))); + .body("content[13].latestNote", allOf(startsWith(note4ModifiedAndTransformed), endsWith("Z"))); restClient.givenDefaultUser().get("/mapView/" + mapId + "?sort=latestNote,desc").then().log() .ifValidationFails(LogDetail.BODY).statusCode(200) .body("content", hasSize(20)) .body("content[0].sourceCode", is("map1 row code 3.")) - .body("content[0].latestNote", is(note4Modified.replace("\"", ""))) + .body("content[0].latestNote", allOf(startsWith(note4ModifiedAndTransformed), endsWith("Z"))) .body("content[1].sourceCode", is("map1 row code 2.")) - .body("content[1].latestNote", is(note3Modified.replace("\"", ""))); + .body("content[1].latestNote", allOf(startsWith(note3ModifiedAndTransformed), endsWith("Z"))); } diff --git a/api/src/test/java/org/snomed/snap2snomed/integration/repository/ProjectResourceIT.java b/api/src/test/java/org/snomed/snap2snomed/integration/repository/ProjectResourceIT.java index fb3b645f..55ba1564 100644 --- a/api/src/test/java/org/snomed/snap2snomed/integration/repository/ProjectResourceIT.java +++ b/api/src/test/java/org/snomed/snap2snomed/integration/repository/ProjectResourceIT.java @@ -426,7 +426,7 @@ public void shouldDeleteProjectAndRelatedEntities() throws Exception { restClient.givenUser(DEFAULT_TEST_ADMIN_USER_SUBJECT) .queryParam("projection", "targetView") .queryParam("row.sourceCode.index", "1") - .queryParam("row.map.id", mapId) + .queryParam("mapId", mapId) .get("/mapRowTargets") .then().statusCode(200) .body("page.totalElements", equalTo(1)); @@ -446,7 +446,7 @@ public void shouldDeleteProjectAndRelatedEntities() throws Exception { restClient.givenUser(DEFAULT_TEST_ADMIN_USER_SUBJECT) .queryParam("projection", "targetView") .queryParam("row.sourceCode.index", "1") - .queryParam("row.map.id", mapId) + .queryParam("mapId", mapId) .get("/mapRowTargets") .then().statusCode(200) .body("page.totalElements", equalTo(0)); diff --git a/api/src/test/resources/test_export.json b/api/src/test/resources/test_export.json new file mode 100644 index 00000000..cdb03289 --- /dev/null +++ b/api/src/test/resources/test_export.json @@ -0,0 +1 @@ +{"resourceType":"ConceptMap","version":"Testing Map Version","name":"ProjectDemo","title":"ProjectDemo","status":"unknown","date":"2023-03-03T13:13:09+10:00","description":"Demo Project","targetUri":"http://snomed.info/sct?fhir_vs=ecl/http://map.test.toscope","group":[{"sourceVersion":"1.2.3","target":"http://snomed.info/sct","targetVersion":"http://snomed.info/sct/32506021000036107/version/20210531","element":[{"code":"map row code 1.","display":"map row display 1","target":[{"code":"abc","display":"XYZ","equivalence":"relatedto"},{"code":"def","display":"D E F","equivalence":"relatedto"}]},{"code":"map row code 2.","display":"map row display 2","target":[{"equivalence":"unmatched"}]},{"code":"map row code 3.","display":"map row display 3","target":[{"equivalence":"unmatched"}]},{"code":"map row code 4.","display":"map row display 4","target":[{"code":"broader","display":"Broader","equivalence":"wider"}]},{"code":"map row code 5.","display":"map row display 5","target":[{"code":"narrower","display":"Narrower","equivalence":"narrower"}]},{"code":"map row code 6.","display":"map row display 6","target":[{"code":"equivalent","display":"Equivalent","equivalence":"equivalent"}]},{"code":"map row code 7.","display":"map row display 7","target":[{"code":"equivalent","display":"Equivalent","equivalence":"relatedto"}]},{"code":"map row code 8.","display":"map row display 8","target":[{"code":"utf8","display":"ሰማይ አይታረስ ንጉሥ አይከሰስ።","equivalence":"equivalent"}]},{"code":"map row code 9.","display":"map row display 9","target":[{"code":"quotes","display":"This is a bit of text that has \"quotes\", as well as some commas to stir things up","equivalence":"equivalent"}]},{"code":"map row code 10.","display":"map row display 10","target":[{"code":"tabby","display":"This display has a tab \t to ensure this is handled","equivalence":"equivalent"}]},{"code":"map row code 11.","display":"map row display 11","target":[{"code":"inreview","display":"in review row","equivalence":"equivalent"}]},{"code":"map row code 12.","display":"map row display 12"},{"code":"map row code 13.","display":"map row display 13"},{"code":"map row code 14.","display":"map row display 14"},{"code":"map row code 15.","display":"map row display 15"},{"code":"map row code 16.","display":"map row display 16"},{"code":"map row code 17.","display":"map row display 17"},{"code":"map row code 18.","display":"map row display 18"},{"code":"map row code 19.","display":"map row display 19"},{"code":"map row code 20.","display":"map row display 20"},{"code":"map row code 21.","display":"map row display 21"},{"code":"map row code 22.","display":"map row display 22"},{"code":"map row code 23.","display":"map row display 23"},{"code":"map row code 24.","display":"map row display 24"},{"code":"map row code 25.","display":"map row display 25"},{"code":"map row code 26.","display":"map row display 26"},{"code":"map row code 27.","display":"map row display 27"},{"code":"map row code 28.","display":"map row display 28"},{"code":"map row code 29.","display":"map row display 29"},{"code":"map row code 30.","display":"map row display 30"},{"code":"map row code 31.","display":"map row display 31"},{"code":"map row code 32.","display":"map row display 32"},{"code":"map row code 33.","display":"map row display 33"},{"code":"map row code 34.","display":"map row display 34"}]}]} \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 9405fb6b..45ac2cc8 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -47,7 +47,7 @@ stages: mavenFeedAuthenticate: true mavenOptions: ' $(mavenOptions)' javaHomeOption: 'JDKVersion' - jdkVersionOption: '1.11' + jdkVersionOption: '1.17' jdkArchitectureOption: 'x64' publishJUnitResults: true testResultsFiles: '**/*/TEST*-*.xml' @@ -70,8 +70,8 @@ stages: displayName: "Run trivy scan on snap2snomed" inputs: script: | - trivy image --exit-code 0 --severity LOW,MEDIUM $(dockerRegistry)/$(registryPath):$(Build.BuildNumber) - trivy image --exit-code 1 --severity HIGH,CRITICAL $(dockerRegistry)/$(registryPath):$(Build.BuildNumber) + trivy image --exit-code 0 --severity LOW,MEDIUM --security-checks vuln --timeout 15m $(dockerRegistry)/$(registryPath):$(Build.BuildNumber) + trivy image --exit-code 1 --severity HIGH,CRITICAL --security-checks vuln --timeout 15m $(dockerRegistry)/$(registryPath):$(Build.BuildNumber) - script: | export VERSION=`git rev-parse --short=7 HEAD` && \ yarn exec sentry-cli releases new $VERSION && \ diff --git a/pom.xml b/pom.xml index 07f6c577..62f95d8d 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,7 @@ org.springframework.boot spring-boot-starter-parent - 2.6.13 + 2.7.14 @@ -33,12 +33,12 @@ org.flywaydb flyway-core 7.15.0 - + org.yaml snakeyaml - 1.32 + 2.2 diff --git a/terraform/api/locals.tf b/terraform/api/locals.tf index eaaa7318..cd5cdab3 100644 --- a/terraform/api/locals.tf +++ b/terraform/api/locals.tf @@ -95,6 +95,10 @@ locals { { name = "snap2snomed.mainPageText", value = var.main_page_text + }, + { + name = "snap2snomed.identityProvider", + value = var.identity_provider } ] dex_ecs_environment = [ diff --git a/terraform/api/main.tf b/terraform/api/main.tf index e8a5b747..19656dff 100644 --- a/terraform/api/main.tf +++ b/terraform/api/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "4.30.0" + version = "4.58.0" } } } diff --git a/terraform/api/variables.tf b/terraform/api/variables.tf index be19d6cc..8b4499ca 100644 --- a/terraform/api/variables.tf +++ b/terraform/api/variables.tf @@ -187,6 +187,11 @@ variable "dex_loglevel" { type = string } +variable "identity_provider" { + description = "Identity provider" + type = string +} + variable "force_dex_deployment" { description = "Force DEX ECS service redeployment" type = bool diff --git a/terraform/main.tf b/terraform/main.tf index cffc873f..32594263 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "4.30.0" + version = "4.58.0" } } backend "remote" { @@ -61,6 +61,7 @@ module "api" { force_dex_deployment = var.force_dex_deployment database_backup_retention_period = var.database_backup_retention_period jumpbox_ami_id = var.jumpbox_ami_id + identity_provider = var.identity_provider } module "ui" { @@ -99,5 +100,6 @@ module "lambda-promtail" { password = var.loki_password log_groups = [module.api.dex_log_group_name, module.api.api_log_group_name] write_address = var.loki_url + } diff --git a/terraform/ui/main.js b/terraform/ui/main.js index f6441b22..43732517 100644 --- a/terraform/ui/main.js +++ b/terraform/ui/main.js @@ -48,7 +48,7 @@ exports.handler = function(event, _, callback) { }], "content-security-policy": [{ key: 'Content-Security-Policy', - value: "default-src 'self' https://snap.snomedtools.org https://*.snap.snomedtools.org https://snap2snomed.app https://*.snap2snomed.app https://ihtsdo.freshdesk.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://snap.snomedtools.org https://*.snap.snomedtools.org https://snap2snomed.app https://*.snap2snomed.app https://d18k7b2git647n.cloudfront.net https://*.sentry.io https://browser.sentry-cdn.com https://js.sentry-cdn.com; connect-src 'self' https://snap.snomedtools.org https://*.snap.snomedtools.org https://snap2snomed.app https://*.snap2snomed.app https://snap2snomed.report-uri.com https://tx.ontoserver.csiro.au https://r4.ontoserver.csiro.au https://auth.ontoserver.csiro.au https://accounts.google.com https://*.auth.eu-central-1.amazoncognito.com https://*.auth.ap-southeast-2.amazoncognito.com https://sentry.io https://*.sentry.io; img-src 'self' https://d18k7b2git647n.cloudfront.net https://www.gravatar.com; style-src 'self' 'unsafe-inline' https://d18k7b2git647n.cloudfront.net https://fonts.googleapis.com/; object-src 'none'; upgrade-insecure-requests; report-uri https://snap2snomed.report-uri.com/r/d/csp/enforce" + value: "default-src 'self' https://snap.snomedtools.org https://*.snap.snomedtools.org https://snap2snomed.app https://*.snap2snomed.app https://ihtsdo.freshdesk.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://snap.snomedtools.org https://*.snap.snomedtools.org https://snap2snomed.app https://*.snap2snomed.app https://d18k7b2git647n.cloudfront.net https://*.sentry.io https://sentry.io https://browser.sentry-cdn.com https://js.sentry-cdn.com; connect-src 'self' https://snap.snomedtools.org https://*.snap.snomedtools.org https://snap2snomed.app https://*.snap2snomed.app https://snap2snomed.report-uri.com https://tx.ontoserver.csiro.au https://r4.ontoserver.csiro.au https://auth.ontoserver.csiro.au https://accounts.google.com https://*.auth.eu-central-1.amazoncognito.com https://*.auth.ap-southeast-2.amazoncognito.com https://sentry.io https://*.sentry.io; img-src 'self' https://d18k7b2git647n.cloudfront.net https://www.gravatar.com; style-src 'self' 'unsafe-inline' https://d18k7b2git647n.cloudfront.net https://fonts.googleapis.com/; object-src 'none'; upgrade-insecure-requests; report-uri https://snap2snomed.report-uri.com/r/d/csp/enforce" }] }; callback(null, response); diff --git a/terraform/ui/main.tf b/terraform/ui/main.tf index 19fae61f..6cc7cf5e 100644 --- a/terraform/ui/main.tf +++ b/terraform/ui/main.tf @@ -2,12 +2,12 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "4.30.0" + version = "4.58.0" configuration_aliases = [aws.us-east-1] } archive = { source = "hashicorp/archive" - version = "2.2.0" + version = "2.3.0" } } } diff --git a/terraform/variables.tf b/terraform/variables.tf index 1f1e1501..3cec3b4d 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -245,6 +245,12 @@ variable "dex_loglevel" { default = "info" } +variable "identity_provider" { + description = "Identity provider" + type = string + default = "" +} + variable "force_dex_deployment" { description = "Force DEX ECS redeployment" type = bool diff --git a/ui/pom.xml b/ui/pom.xml index af93c139..e2e83aa9 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -51,7 +51,7 @@ com.github.eirslett frontend-maven-plugin - 1.12.1 + 1.14.2 ./snapclient v14.16.1 @@ -117,10 +117,10 @@ maven-resources-plugin - 3.3.0 + 3.3.1 - copy-resources + copy-resources-filtered generate-resources copy-resources @@ -130,11 +130,33 @@ ${basedir}/snapclient/dist/snapclient + + **/Snap2X.pdf + true + + copy-resources-unfiltered + generate-resources + + copy-resources + + + ${basedir}/target/site + + + ${basedir}/snapclient/dist/snapclient + + **/Snap2X.pdf + + false + + + + @@ -173,4 +195,4 @@ - \ No newline at end of file + diff --git a/ui/snapclient/src/app/_models/map_row.ts b/ui/snapclient/src/app/_models/map_row.ts index 39bfc50b..bf2eb4d4 100644 --- a/ui/snapclient/src/app/_models/map_row.ts +++ b/ui/snapclient/src/app/_models/map_row.ts @@ -19,6 +19,9 @@ import {SourceCode} from './source_code'; import {Mapping} from './mapping'; import {User} from './user'; +export const TARGET_OUT_OF_SCOPE_TAG = "target-out-of-scope"; +export const TARGET_NO_ACTIVE_SUGGESTIONS_TAG = "target-no-active-suggestions-tag"; + export interface MapRow { id: string | null; map?: Mapping; @@ -27,19 +30,23 @@ export interface MapRow { status: string; created: Date; modified: Date; + modifiedBy: User; latestNote: Date | null; } /** Outer join of MapRow and MapRowTarget */ export class MapView { rowId: string; // MapRow ID + sourceId: string; sourceIndex: string; sourceCode: string; sourceDisplay: string; - assignedAuthor?: User | null; + assignedAuthor?: User[] | null; + assignedReconciler?: User | null; assignedReviewer?: User | null; lastAuthor?: User | null; lastReviewer?: User | null; + targetLastAuthor?: User | null; targetId?: string; // TargetRow ID targetCode?: string; @@ -49,6 +56,8 @@ export class MapView { noMap: boolean; latestNote?: Date | null; flagged?: boolean; + targetOutOfScope?: boolean; + tags?: string[]; additionalColumnValues: string[]; @@ -60,13 +69,15 @@ export class MapView { private prevNoMap: boolean; private prevFlagged?: boolean; - constructor(rowId: string, targetId: string | undefined, sourceIndex: string, sourceCode: string, sourceDisplay: string, + constructor(rowId: string, targetId: string | undefined, sourceId: string, sourceIndex: string, sourceCode: string, sourceDisplay: string, targetCode: string | undefined, targetDisplay: string | undefined, relationship: string | undefined, - status: string, noMap: boolean, latestNote: Date | null | undefined, assignedAuthor: User | null | undefined, - assignedReviewer: User | null | undefined, lastAuthor: User | null | undefined, - lastReviewer: User | null | undefined, flagged: boolean | undefined, additionalColumnValues: string[] | undefined) { + status: string, noMap: boolean, latestNote: Date | null | undefined, assignedAuthor: User[] | null | undefined, + assignedReconciler: User | null | undefined, assignedReviewer: User | null | undefined, lastAuthor: User | null | undefined, + lastReviewer: User | null | undefined, flagged: boolean | undefined, targetOutOfScope: boolean | undefined, tags: string[] | undefined, + additionalColumnValues: string[] | undefined, targetLastAuthor: User | null | undefined) { this.rowId = rowId; this.targetId = targetId; + this.sourceId = sourceId; this.sourceIndex = sourceIndex; this.sourceCode = sourceCode; this.sourceDisplay = sourceDisplay; @@ -76,12 +87,18 @@ export class MapView { this.prevStatus = this.status = status; this.prevNoMap = this.noMap = noMap; this.prevFlagged = this.flagged = flagged; + this.targetOutOfScope = targetOutOfScope; + this.tags = tags; + this.latestNote = latestNote; this.assignedAuthor = assignedAuthor; + this.assignedReconciler = assignedReconciler; this.assignedReviewer = assignedReviewer; + this.assignedReconciler = assignedReconciler; this.lastAuthor = lastAuthor; this.lastReviewer = lastReviewer; this.additionalColumnValues = additionalColumnValues || []; + this.targetLastAuthor = targetLastAuthor; } static create(mv: any): MapView { @@ -89,11 +106,13 @@ export class MapView { const rowId = mv.rowId === null ? '' : mv.rowId.toString(); const additionalColumnValues = mv.additionalColumns ? mv.additionalColumns.map((ac: {value: string}) => ac.value) : []; + const targetOutOfScope = mv.targetTags?.includes(TARGET_OUT_OF_SCOPE_TAG); return new MapView( - rowId, mv.targetId, mv.sourceIndex, mv.sourceCode, mv.sourceDisplay, + rowId, mv.targetId, mv.sourceId, mv.sourceIndex, mv.sourceCode, mv.sourceDisplay, mv.targetCode, mv.targetDisplay, mv.relationship, mv.status, mv.noMap, mv.latestNote, - mv.assignedAuthor, mv.assignedReviewer, mv.lastAuthor, mv.lastReviewer, mv.flagged, additionalColumnValues + mv.assignedAuthor, mv.assignedReconciler, mv.assignedReviewer, mv.lastAuthor, mv.lastReviewer, + mv.flagged, targetOutOfScope, mv.targetTags, additionalColumnValues, mv.targetLastAuthor ); } @@ -124,6 +143,7 @@ export class MapView { this.prevTargetDisplay = this.targetDisplay = targetRow.targetDisplay; this.prevRelationship = this.relationship = targetRow.relationship; this.prevFlagged = this.flagged = targetRow.flagged; + this.targetOutOfScope = targetRow.targetOutOfScope; } reset(): void { @@ -166,8 +186,10 @@ export class MapViewFilter { noMap?: boolean | undefined; lastAuthorReviewer: string[] | string = ''; assignedAuthor: string[] | string = ''; + assignedReconciler: string[] | string = ''; assignedReviewer: string[] | string = ''; flagged?: boolean | undefined; + targetOutOfScope?: boolean | undefined; notes?: boolean | undefined; additionalColumns : string[] = []; @@ -175,8 +197,9 @@ export class MapViewFilter { const filteredAdditionalColumns: string[] = this.additionalColumns.length > 0 ? this.additionalColumns.filter((s): s is string => Boolean(s)) : []; return this.sourceCode !== '' || this.sourceDisplay !== '' || this.targetCode !== '' || this.targetDisplay !== '' - || this.relationship !== '' || this.status !== '' || this.noMap !== undefined || this.flagged !== undefined - || this.lastAuthorReviewer !== '' || this.assignedAuthor !== '' || this.assignedReviewer !== '' || this.notes !== undefined || filteredAdditionalColumns.length > 0; + || this.relationship !== '' || this.status !== '' || this.noMap !== undefined || this.flagged !== undefined || this.targetOutOfScope !== undefined + || this.lastAuthorReviewer !== '' || this.assignedAuthor !== '' || this.assignedReconciler !== '' || this.assignedReviewer !== '' + || this.notes !== undefined || filteredAdditionalColumns.length > 0; } } @@ -253,7 +276,8 @@ export const enum MapRowStatus { MAPPED = 'MAPPED', INREVIEW = 'INREVIEW', ACCEPTED = 'ACCEPTED', - REJECTED = 'REJECTED' + REJECTED = 'REJECTED', + RECONCILE = 'RECONCILE' } export function toMapRowStatus(statusString?: string): MapRowStatus | null { @@ -274,6 +298,8 @@ export function mapRowStatusToIconName(status: MapRowStatus): string { return 'done_all'; case MapRowStatus.REJECTED: return 'cancel'; + case MapRowStatus.RECONCILE: + return 'compare_arrows'; default: return 'circle'; } @@ -291,7 +317,12 @@ export const reviewStatuses = [ MapRowStatus.REJECTED ]; -export const mapRowStatuses: MapRowStatus[] = authorStatuses.concat(reviewStatuses); +export const reconcileStatuses = [ + MapRowStatus.RECONCILE, + MapRowStatus.MAPPED +] + +export const mapRowStatuses: MapRowStatus[] = authorStatuses.concat(reviewStatuses).concat(reconcileStatuses); export const mapRowRelationships = [ MapRowRelationship.EQUIVALENT, diff --git a/ui/snapclient/src/app/_models/mapping.ts b/ui/snapclient/src/app/_models/mapping.ts index 4415da24..8dce7078 100644 --- a/ui/snapclient/src/app/_models/mapping.ts +++ b/ui/snapclient/src/app/_models/mapping.ts @@ -32,6 +32,7 @@ export class Mapping { this.toScope = '*'; // ECL for "all SNOMED concepts" - cannot use 'ANY' because SnowStorm this.created = new Date(); this.modified = new Date(); + this.blindMapFlag = false; } id: string | null; @@ -43,6 +44,7 @@ export class Mapping { toScope: string; created: Date; modified: Date; + blindMapFlag: Boolean; static replacer(key: string, value: any): any { if (value !== null && key === 'project') { diff --git a/ui/snapclient/src/app/_models/note.ts b/ui/snapclient/src/app/_models/note.ts index 7724b394..4f7542b2 100644 --- a/ui/snapclient/src/app/_models/note.ts +++ b/ui/snapclient/src/app/_models/note.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,10 @@ import {User} from './user'; import {MapRow} from './map_row'; +export const enum NoteCategory { + USER = 'USER', + STATUS = 'STATUS' +} export class Note { id: number | null; @@ -25,14 +29,16 @@ export class Note { created: Date; modified: Date; mapRow: MapRow; + category: NoteCategory; - constructor(id: number | null, noteText: string, noteBy: User, created: string, modified: string, mapRow: MapRow) { + constructor(id: number | null, noteText: string, noteBy: User, created: string, modified: string, mapRow: MapRow, category: NoteCategory) { this.id = id; this.noteText = noteText; this.noteBy = noteBy; this.created = new Date(created); this.modified = new Date(modified); this.mapRow = mapRow; + this.category = category } static replacer(key: string, value: any): any { diff --git a/ui/snapclient/src/app/_models/project.ts b/ui/snapclient/src/app/_models/project.ts index bc265d80..e752890b 100644 --- a/ui/snapclient/src/app/_models/project.ts +++ b/ui/snapclient/src/app/_models/project.ts @@ -28,6 +28,7 @@ export class Project { modified: Date; maps: Mapping[]; mapcount: number | null; + dualMapMode: boolean; owners: User[]; members: User[]; guests: User[]; @@ -38,6 +39,7 @@ export class Project { this.description = null; this.created = new Date(); this.modified = new Date(); + this.dualMapMode = false; this.maps = []; this.owners = []; this.members = []; diff --git a/ui/snapclient/src/app/_models/source.ts b/ui/snapclient/src/app/_models/source.ts index 50e224e5..2ab77dd2 100644 --- a/ui/snapclient/src/app/_models/source.ts +++ b/ui/snapclient/src/app/_models/source.ts @@ -31,6 +31,8 @@ export class Source { id: string | null; name: string; version: string; + codesystemUri?: string; + valuesetUri?: string; description: string | null; hasHeader: boolean | null; contentType: string; diff --git a/ui/snapclient/src/app/_models/target_row.ts b/ui/snapclient/src/app/_models/target_row.ts index 743766e7..1ae7af56 100644 --- a/ui/snapclient/src/app/_models/target_row.ts +++ b/ui/snapclient/src/app/_models/target_row.ts @@ -20,6 +20,8 @@ import {SourceCode} from './source_code'; import {Mapping} from './mapping'; import {MapRow} from './map_row'; +import {User} from './user'; +import { TaskType } from './task'; export class TargetRow { row?: string; @@ -28,15 +30,22 @@ export class TargetRow { targetDisplay?: string; relationship?: string; flagged?: boolean; + targetOutOfScope?: boolean; + tags?: string[]; + taskType?: TaskType; constructor(row: string | undefined, id: string | undefined, targetCode: string | undefined, targetDisplay: string | undefined, - relationship: string | undefined, flagged: boolean | undefined) { + relationship: string | undefined, flagged: boolean | undefined, targetOutOfScope: boolean | undefined, tags: string[] | undefined, + taskType: TaskType | undefined) { this.row = row; this.id = id; this.targetCode = targetCode; this.targetDisplay = targetDisplay; this.relationship = relationship; this.flagged = flagged; + this.targetOutOfScope = targetOutOfScope; + this.tags = tags; + this.taskType = taskType; } static replacer(key: string, value: any): any { @@ -50,14 +59,19 @@ export class TargetRow { export class JSONTargetRow extends TargetRow { source?: SourceCode; mapping?: Mapping; + lastAuthor?: User; // @ts-ignore row?: MapRow; + tags?: string[]; - constructor(row: MapRow | undefined, id: string | undefined, targetCode: string | undefined, targetDisplay: string | undefined, - relationship: string | undefined, flagged: boolean, source: SourceCode | undefined, mapping: Mapping | undefined) { - super(row?.id || '', id, targetCode, targetDisplay, relationship, flagged); + constructor(row: MapRow | undefined, id: string | undefined, targetCode: string | undefined, tags: string[] | undefined, targetDisplay: string | undefined, + relationship: string | undefined, flagged: boolean, targetOutOfScope: boolean, source: SourceCode | undefined, mapping: Mapping | undefined, + lastAuthor: User | undefined, task: TaskType | undefined) { + super(row?.id || '', id, targetCode, targetDisplay, relationship, flagged, targetOutOfScope, tags, task); this.source = source; this.mapping = mapping; this.row = row; + this.tags = tags; + this.lastAuthor = lastAuthor } } diff --git a/ui/snapclient/src/app/_models/task.ts b/ui/snapclient/src/app/_models/task.ts index 5365fc49..e3faaf84 100644 --- a/ui/snapclient/src/app/_models/task.ts +++ b/ui/snapclient/src/app/_models/task.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,8 @@ import {User} from './user'; export const enum TaskType { AUTHOR = 'AUTHOR', - REVIEW = 'REVIEW' + REVIEW = 'REVIEW', + RECONCILE = 'RECONCILE' } export const enum TaskConflictType { @@ -30,7 +31,7 @@ export const enum TaskConflictType { export class Task { id: string; - type: TaskType | string; + type: TaskType; description?: string; mapping: Mapping; assignee: User; @@ -51,7 +52,7 @@ export class Task { } - constructor(id: string, type: TaskType | string, description: string | undefined, + constructor(id: string, type: TaskType, description: string | undefined, mapping: Mapping, assignee: User, sourceRowSpecification: string, sourceRowCount: number, createdString: string | undefined, modifiedString: string | undefined, reassignAlreadyAssignedRows: boolean, @@ -80,6 +81,10 @@ export class Task { public isReview(): boolean { return this.type === TaskType.REVIEW; } + + public isReconcile(): boolean { + return this.type === TaskType.RECONCILE; + } } export class IndexSpecification { diff --git a/ui/snapclient/src/app/_services/auth.service.ts b/ui/snapclient/src/app/_services/auth.service.ts index f96ff9ec..703341d5 100644 --- a/ui/snapclient/src/app/_services/auth.service.ts +++ b/ui/snapclient/src/app/_services/auth.service.ts @@ -49,6 +49,7 @@ export class AuthService { private authLoginResponseType = this.config.authLoginResponseType; private authLoginScope = this.config.authLoginScope; private authLoginGrantType = this.config.authLoginGrantType; + private identityProvider = this.config.identityProvider; private authLoginRedirectUrl = window.location.origin; private authLogout = window.location.origin + '/'; @@ -65,13 +66,24 @@ export class AuthService { // clear SessionStorage first this.clearSessionStorage(); if (this.baseUrl.length > 5) { - const params = new HttpParams() + let href = `${this.baseUrl}`; + let params = new HttpParams() .set('client_id', this.authClientID) .set('response_type', this.authLoginResponseType) .set('scope', this.authLoginScope) .set('redirect_uri', this.authLoginRedirectUrl); + + if (this.identityProvider) { + params = params.set('identity_provider', this.identityProvider); + href += `/authorize`; + } + else { + href += `/login`; + } + + href += `?${params.toString()}`; // Redirect to AWS Cognito hosted UI - window.location.href = `${this.baseUrl}/login?${params.toString()}`; + window.location.href = href; } else { throwError({error: `Login unsuccessful - missing URL ${this.baseUrl}`}); } diff --git a/ui/snapclient/src/app/_services/fhir.service.ts b/ui/snapclient/src/app/_services/fhir.service.ts index aaada89d..31b4740d 100644 --- a/ui/snapclient/src/app/_services/fhir.service.ts +++ b/ui/snapclient/src/app/_services/fhir.service.ts @@ -16,11 +16,11 @@ import {Inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; -import {Observable, of} from 'rxjs'; +import {Observable, forkJoin, of} from 'rxjs'; import {ServiceUtils} from '../_utils/service_utils'; import {APP_CONFIG, AppConfig} from '../app.config'; import {R4} from '@ahryman40k/ts-fhir-types'; -import {catchError, map, mergeMap} from 'rxjs/operators'; +import {catchError, concatMap, map, mergeMap, tap} from 'rxjs/operators'; import {ErrorDetail} from '../_models/error_detail'; import {BundleTypeKind, Bundle_RequestMethodKind} from '@ahryman40k/ts-fhir-types/lib/R4'; import {Coding, Match} from '../store/fhir-feature/fhir.reducer'; @@ -51,6 +51,65 @@ export class FhirService { this.config.fhirBaseUrl.indexOf('ontoserver') > 0 : false; } + findReplacementConcepts(conceptId: string, scope: string, version: string) { + + return forkJoin({ + sameAs: this.findSameAsConcepts(conceptId, scope, version), + replacedBy: this.findReplacedByConcepts(conceptId, scope, version), + possiblyEquivalentTo: this.findPossiblyEquivalentTo(conceptId, scope, version), + alternative: this.findAlternative(conceptId, scope, version), + code: conceptId + }); + + } + + //Same As : http://snomed.info/sct[/edition[/version]]?fhir_cm=900000000000527005 (target ValueSet is http://snomed.info/sct?fhir_vs) + findSameAsConcepts(conceptId: string, scope: string, version: string) { + return this.findHistoricalAssociation("900000000000527005", conceptId, scope, version); + } + + //Replaced By : http://snomed.info/sct[/edition[/version]]?fhir_cm=900000000000526001 (target ValueSet is http://snomed.info/sct?fhir_vs) + findReplacedByConcepts(conceptId: string, scope: string, version: string) { + return this.findHistoricalAssociation("900000000000526001", conceptId, scope, version); + } + + //Possibly Equivalent To : http://snomed.info/sct[/edition[/version]]?fhir_cm=900000000000523009 (target ValueSet is http://snomed.info/sct?fhir_vs) + findPossiblyEquivalentTo(conceptId: string, scope: string, version: string) { + return this.findHistoricalAssociation("900000000000523009", conceptId, scope, version); + } + + //Alternative : http://snomed.info/sct[/edition[/version]]?fhir_cm=900000000000530003 (target ValueSet is http://snomed.info/sct?fhir_vs) + findAlternative(conceptId: string, scope: string, version: string) { + return this.findHistoricalAssociation("900000000000530003", conceptId, scope, version); + } + + + findHistoricalAssociation(cmId: string, conceptId: string, scope: string, version: string) { + const url = `${this.config.fhirBaseUrl}/ConceptMap/$translate`; + const body: R4.IParameters = { + resourceType: "Parameters", + parameter: [ + { + name: "url", + valueUri: version + "?fhir_cm=" + cmId + }, + { + name: "coding", + valueCoding: { + system: "http://snomed.info/sct", + code: conceptId + } + } + ] + }; + + const options = ServiceUtils.getHTTPHeaders(); + options.headers = options.headers + .set('Content-Type', 'application/fhir+json'); + return this.http.post(url, body, options); + } + + findOutliers(toSystem: string, toVersion: string, targets: string[], toScope: string) { const valueSet: R4.IValueSet = { resourceType: 'ValueSet', @@ -156,6 +215,121 @@ export class FhirService { return system + '|' + code; } + displayResolvedLookupConcept(code: string, system: string, version: string, properties: string[] = []): Observable { + + let expandList = ""; + let conceptDisplayMap = new Map(); + + return this.lookupConcept(code, system, version, properties).pipe( + tap(parameters => { + // Step 1. find all the concept codes in the properties so their display can be looked up + parameters.parameter?.map(param => { + if ("property" === param.name) { + let value; + + param.part?.forEach(part => { + if (part.name) { + if ('value' === part.name) { + if (part.valueCode) { + value = part.valueCode + if (expandList.length > 0) { + expandList += " OR "; + } + expandList += value; + } + } + else if ('subproperty' === part.name) { + part.part?.map(subPropPart => { + if (subPropPart.hasOwnProperty('valueCode')) { + if (expandList.length > 0) { + expandList += " OR "; + } + expandList += subPropPart.valueCode; + } + }) + } + else if ('code' === part.name && part.valueCode && !isNaN(+part.valueCode)) { + // pick up the attribute relationships that do not reside in a role group + if ('609096000' !== part.valueCode) { // role group + if (expandList.length > 0) { + expandList += " OR "; + } + expandList += part.valueCode; + + } + + } + } + + }) + + } + }) + }), + concatMap(parameters => { + + // 2. Look up the display for the concept codes + + const url = `${this.config.fhirBaseUrl}/ValueSet/$expand`; + const params: any = { + url: FhirService.toValueSet('http://snomed.info/sct', expandList), + }; + + const options = ServiceUtils.getHTTPHeaders(); + options.headers = options.headers + .set('Accept', ['application/fhir+json', 'application/json']); + + options.params = {...options.params, ...params}; + return this.http.get(url, options).pipe( + tap(res => { + if (res.resourceType === 'ValueSet') { + res.expansion?.contains?.map(param => { + if (param.code && param.display) { + conceptDisplayMap.set(param.code, param.display); + } + }) + } + }), + map(res => { + + // 3. update the parameters with display terms for display in the UI + + parameters.parameter?.map(param => { + if ("property" === param.name) { + let parameterCode : string | undefined; + + param.part?.forEach(paramPart => { + if ('value' === paramPart.name) { + parameterCode = paramPart.valueCode; + param.part?.push({name: 'valueString', valueString: conceptDisplayMap.get(parameterCode!)}); + } + else if ('subproperty' === paramPart.name) { + paramPart.part?.map(subpart => { + if (subpart.valueCode) { + if ('code' === subpart.name || 'value' === subpart.name || 'valueCode' === subpart.name) { + paramPart.part?.push({name: subpart.name, valueString: conceptDisplayMap.get(subpart.valueCode)}); + } + } + }) + } + else if ('code' === paramPart.name && paramPart.valueCode && !isNaN(+paramPart.valueCode)) { + // pick up the attribute relationships that do not reside in a role group + if ('609096000' !== paramPart.valueCode) { // role gorup + parameterCode = paramPart.valueCode; + param.part?.push({name: 'code', valueString: conceptDisplayMap.get(parameterCode!)}); + } + + } + }) + } + }) + return parameters; + }) + ) + }) + ); + } + conceptHierarchy(code: string, system: string, version: string): Observable[]> { const toId = FhirService.conceptNodeId; @@ -343,6 +517,24 @@ export class FhirService { return version + '?fhir_vs=ecl/' + encodeURIComponent(ecl.replace(/\s+/g, ' ')); } + validateConceptInScopeAndActive(code: string, system: string, version: string, scope: string): Observable { + + const url = `${this.config.fhirBaseUrl}/ValueSet/$validate-code`; + + const params: any = { + 'code': code, + 'system': system, + 'url': FhirService.toValueSet(version, scope) + }; + + const options = ServiceUtils.getHTTPHeaders(); + options.headers = options.headers + .set('Accept', ['application/fhir+json', 'application/json']); + options.params = {...options.params, ...params}; + return this.http.get(url, options); + + } + validateEcl(ecl: string): Observable<{ valid: boolean, detail?: any }> { const url = `${this.config.fhirBaseUrl}/ValueSet/$expand`; const params: any = { diff --git a/ui/snapclient/src/app/_services/map.service.ts b/ui/snapclient/src/app/_services/map.service.ts index 746de50a..885f5bf7 100644 --- a/ui/snapclient/src/app/_services/map.service.ts +++ b/ui/snapclient/src/app/_services/map.service.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,11 +22,12 @@ import {Project} from '../_models/project'; import {ServiceUtils} from '../_utils/service_utils'; import {AdditionalColumn, MappedRowDetailsDto, MapRow, MapRowRelationship, MapRowStatus, MapView} from '../_models/map_row'; import {JSONTargetRow, TargetRow} from '../_models/target_row'; -import {Note} from '../_models/note'; +import {Note, NoteCategory} from '../_models/note'; import {APP_CONFIG, AppConfig} from '../app.config'; import {map} from 'rxjs/operators'; import {ImportMappingFileParams} from '../store/source-feature/source.actions'; import {ValidationResult} from "../_models/validation_result"; +import { Task, TaskType } from '../_models/task'; export interface TaskResults { _embedded: any; @@ -115,6 +116,7 @@ export interface MappingDto { status: string | null | undefined; relationship: string | null | undefined; clearTarget: boolean | null | undefined; + resetDualMap: boolean | null |undefined; } export interface MappingUpdateDto { @@ -124,6 +126,7 @@ export interface MappingUpdateDto { export interface CreateMappingParams { mapping: Mapping; importFile: ImportMappingFileParams | undefined | null; + dualMapMode: boolean; } @Injectable({ @@ -143,11 +146,12 @@ export class MapService { return this.http.post(url, body, header); } - createMapping(mapping: Mapping, projectid: string, sourceid: string): Observable { + createMapping(mapping: Mapping, projectid: string, sourceid: string, dualMapMode: boolean): Observable { const url = `${this.config.apiBaseUrl}/maps?projection=listView`; const header = ServiceUtils.getHTTPHeaders(); mapping.project.id = projectid; mapping.source.id = sourceid; + mapping.project.dualMapMode = dualMapMode; const body = JSON.stringify(mapping, Mapping.replacer); return this.http.post(url, body, header); } @@ -258,6 +262,19 @@ export class MapService { return this.getView(url, pageIndex, pageSize, sortColumn, sortDir, filter); } + getSiblingMapViewRow(mapId: string, sourceCodeId: string, mapRowId: string): Observable { + + const url = `${this.config.apiBaseUrl}/mapView/${mapId}/$dualMapSiblingRow`; + const header = ServiceUtils.getHTTPHeaders(); + + let params = new HttpParams(); + params = params.set('sourceCodeId', sourceCodeId); + params = params.set('mapRowId', mapRowId); + header.params = params; + + return this.http.get(url, header); + } + /** * Server-side pagination and filtering of mapViews * 1. Pagination ?map=3&sort=sourceCode,asc&page=1 @@ -329,7 +346,7 @@ export class MapService { * @param mapView * @private */ - updateMapRowTarget(mapView: MapView): Observable { + updateMapRowTarget(mapView: MapView, taskType: TaskType): [Observable, TargetRow] { const header = ServiceUtils.getHTTPHeaders(); const rowId = mapView.rowId; @@ -339,18 +356,21 @@ export class MapService { mapView.targetCode, mapView.targetDisplay, getValidRelationship(mapView), - mapView.flagged + mapView.flagged, + mapView.targetOutOfScope, + mapView.tags, + taskType ); const targetUrl = `${this.config.apiBaseUrl}/mapRowTargets`; if (target.id) { - return this.http.put(`${targetUrl}/${target.id}`, target, header).pipe( - map(toTargetRow), - ); + return [this.http.put(`${targetUrl}/${target.id}`, target, header).pipe( + map(toTargetRow) + ), target]; } else { - return this.http.post(targetUrl, target, header).pipe( + return [this.http.post(targetUrl, target, header).pipe( map(toTargetRow), - ); + ), target]; } } @@ -359,11 +379,17 @@ export class MapService { * @param rowId Id of row * @param noMap true or false */ - updateNoMap(rowId: string, noMap: boolean): Observable { + updateNoMap(rowId: string, noMap: boolean, reconcileTask: boolean): Observable { const url = `${this.config.apiBaseUrl}/mapRows/${rowId}`; const header = ServiceUtils.getHTTPHeaders(); const status = noMap ? MapRowStatus.DRAFT : MapRowStatus.UNMAPPED; - const body = {noMap, status}; + let body : {}; + if (reconcileTask) { + body = {noMap}; + } + else { + body = {noMap, status}; + } return this.http.patch(url, body, header); } @@ -381,8 +407,25 @@ export class MapService { return this.http.patch(url, body, header); } - exportMapView(mapping: string, contentType: string): Observable { - return this.http.get(`${this.config.apiBaseUrl}/mapView/${mapping}`, + updateTags(targetId: string, tags: string[]): Observable { + const url = `${this.config.apiBaseUrl}/mapRowTargets/${targetId}`; + const header = ServiceUtils.getHTTPHeaders(); + const body = {tags}; + return this.http.patch(url, body, header); + } + + getTagCount(mapId: string, tag: string): Observable { + const url = `${this.config.apiBaseUrl}/mapRowTargets?tags=${tag}&mapId=${mapId}`; + const header = ServiceUtils.getHTTPHeaders(); + return this.http.get(url, header); + } + + exportMapView(mapping: string, contentType: string, extraColumns: string[]): Observable { + let url = `${this.config.apiBaseUrl}/mapView/${mapping}`; + if (extraColumns.length > 0) { + url += `?extraColumns=` + extraColumns.join(","); + } + return this.http.get(url, { headers: {Accept: contentType}, responseType: 'blob' @@ -405,13 +448,30 @@ export class MapService { /** * Get Targets for Source Code */ - findTargetsBySourceIndex(map_id: string, source_idx: string): Observable { + findTargetsBySourceIndex(map_id: string, source_idx: string, task: Task | undefined): Observable { const url = `${this.config.apiBaseUrl}/mapRowTargets`; const header = ServiceUtils.getHTTPHeaders(); - header.params = new HttpParams() + + if (task?.type === TaskType.AUTHOR) { + header.params = new HttpParams() .set('projection', 'targetView') - .set('row.map.id', map_id) + .set('mapId', map_id) + .set('row.sourceCode.index', source_idx) + .set('row.authorTask.id', task.id); + } + else if (task?.type === TaskType.RECONCILE) { + header.params = new HttpParams() + .set('projection', 'targetView') + .set('mapId', map_id) + .set('row.sourceCode.index', source_idx) + .set('row.reconcileTask.id', task.id); + } + else { + header.params = new HttpParams() + .set('projection', 'targetView') + .set('mapId', map_id) .set('row.sourceCode.index', source_idx); + } return this.http.get(url, header); } @@ -436,12 +496,25 @@ export class MapService { * Get all notes by Source Row ID * @param mapRow ID of MapRow */ - getNotesByMapRow(mapRow: string): Observable { - const url = `${this.config.apiBaseUrl}/notes/search/findByMapRowId`; + getNotesByMapRow(mapRow: string, category?: NoteCategory): Observable { + + let url: string; const header = ServiceUtils.getHTTPHeaders(); - header.params = new HttpParams() + + if (category) { + url = `${this.config.apiBaseUrl}/notes/search/findByMapRowIdAndCategory`; + header.params = new HttpParams() + .set('projection', 'noteView') + .set('id', mapRow) + .set('category', category); + } + else { + url = `${this.config.apiBaseUrl}/notes/search/findByMapRowId`; + header.params = new HttpParams() .set('projection', 'noteView') .set('id', mapRow); + } + return this.http.get(url, header); } @@ -459,7 +532,7 @@ export class MapService { } /** - * Bulk Uodate Update mapping + * Bulk update mapping for selection of * @param mappingUpdate * @private */ @@ -487,10 +560,10 @@ export class MapService { * @param mappingUpdate * @private */ - public bulkUpdateAllRowsForMap(mapId: string, mappingDto: MappingDto): Observable { + public bulkUpdateAllRowsForMap(mapId: string, mappingUpdateDto: MappingUpdateDto): Observable { const url = `${this.config.apiBaseUrl}/updateMapping/map/${mapId}`; const header = ServiceUtils.getHTTPHeaders(); - const body = mappingDto; + const body = mappingUpdateDto; return this.http.post(url, body, header); } @@ -511,5 +584,5 @@ function getValidRelationship(mapView: MapView): string | undefined { function toTargetRow(result: any): TargetRow { const id = ServiceUtils.extractIdFromHref(result._links?.self.href, null); return new TargetRow(undefined, id, result.targetCode, result.targetDisplay, - result.relationship, result.flagged); + result.relationship, result.flagged, result.targetOutOfScope, result.tags, result.type); } diff --git a/ui/snapclient/src/app/_services/source-navigation.service.ts b/ui/snapclient/src/app/_services/source-navigation.service.ts index b334032d..e14719f0 100644 --- a/ui/snapclient/src/app/_services/source-navigation.service.ts +++ b/ui/snapclient/src/app/_services/source-navigation.service.ts @@ -20,6 +20,8 @@ import {MapView, Page} from '../_models/map_row'; import {catchError, map} from 'rxjs/operators'; import {MapService} from './map.service'; import {ViewContext} from '../store/mapping-feature/mapping.actions'; +import { Mapping } from '../_models/mapping'; +import { Task, TaskType } from '../_models/task'; interface MapViewParams { page: Page; @@ -29,6 +31,7 @@ interface MapViewParams { export interface SourceNavSet { mapRow: MapView | null; + siblingRow: MapView | null; current: MapViewParams | null; previous: MapViewParams | null; next: MapViewParams | null; @@ -36,6 +39,7 @@ export interface SourceNavSet { export const initialSourceNav: SourceNavSet = { mapRow: null, + siblingRow: null, current: null, previous: null, next: null @@ -80,8 +84,8 @@ export class SourceNavigationService { * @param params View filters and sorting * @param row_idx Index of row in the view */ - loadSourceNav(task_id: string, params: ViewContext, row_idx: number): void { - this.setSourceNavigation(task_id, row_idx, params); + loadSourceNav(task: Task, mapping : Mapping | null, params: ViewContext, row_idx: number): void { + this.setSourceNavigation(task, mapping, row_idx, params, null); } private findAdjacentSource(task_id: string, params: ViewContext, sourceIndex: string, @@ -126,8 +130,9 @@ export class SourceNavigationService { } } - select(task_id: string, row: MapViewParams): void { - this.setSourceNavigation(task_id, row.rowIndex, row.params); + select(task: Task, mapping: Mapping | null, row: MapViewParams): void { + let rowId = row.page.data[row.rowIndex].rowId; + this.setSourceNavigation(task, mapping, row.rowIndex, row.params, rowId); } /** @@ -138,11 +143,13 @@ export class SourceNavigationService { * @param params - View filters and sorting * @param task - Current Task * @param page - Page representation of the view + * @param rowId - null unless for the reconcile task and if called from next / previous */ - private setSourceNavigation(task_id: string, row_idx: number | null, params: ViewContext): void { + private setSourceNavigation(task: Task, mapping: Mapping | null, row_idx: number | null, params: ViewContext, rowId: string | null): void { if (row_idx !== null) { + let siblingRow: MapView | null = null; // Refresh page.data to avoid bugs with changes to status or noMap - this.mapService.getTaskView(task_id, + this.mapService.getTaskView(task.id, params.pageIndex, params.pageSize, params.sortColumn, params.sortDir, params.filter) .pipe( map((result) => { @@ -150,47 +157,79 @@ export class SourceNavigationService { return new Page(rows, result.page.number, result.page.size, result.page.totalElements, result.page.totalPages); }), ).subscribe((page: Page) => { - const selectedRow = page.data[row_idx]; - const prev_row_idx = SourceNavigationService.prevUniqueRow(page.data, row_idx - 1, selectedRow.sourceIndex); - const next_row_idx = SourceNavigationService.nextUniqueRow(page.data, row_idx + 1, selectedRow.sourceIndex); - - const prevParams = {...params}; - const nextParams = {...params}; - const sourceNav: SourceNavSet = { - mapRow: selectedRow, - current: { - page, - rowIndex: row_idx, - params - }, - previous: prev_row_idx !== null ? { - page, - rowIndex: prev_row_idx, - params: prevParams - } : null, - next: next_row_idx !== null ? { - page, - rowIndex: next_row_idx, - params: nextParams - } : null - }; - - // Find previous source - may be previous page - if (prev_row_idx === null && page.pageIndex > 0) { - prevParams.pageIndex = page.pageIndex - 1; - this.findAdjacentSource(task_id, prevParams, selectedRow.sourceIndex, 'PREVIOUS', sourceNav); - } - // Find next source - may be next page - if (next_row_idx === null && page.pageIndex < page.totalPages) { - nextParams.pageIndex = page.pageIndex + 1; - this.findAdjacentSource(task_id, nextParams, selectedRow.sourceIndex, 'NEXT', sourceNav); + let selectedRow = page.data[row_idx]; + // This code is here because the above "refresh" can result in a smaller amount of rows if rows were + // removed (e.g. due to a row being reconciled and put into the mapped state) + if (task.type === TaskType.RECONCILE && rowId && (!selectedRow || selectedRow.rowId !== rowId)) { + let alternativeRow = page.data.find(row => row.rowId == rowId) + if (alternativeRow !== undefined) { + selectedRow = alternativeRow; + } } - if (selectedRow) { - this.setSelectedRow(sourceNav); + if (mapping !== null && mapping.id && mapping.project.dualMapMode && task.type == TaskType.RECONCILE) { + // call to get the sibling row as we may not have it (all) in the page of data retrieved + this.mapService.getSiblingMapViewRow(mapping.id, selectedRow.sourceId, selectedRow.rowId) + .pipe( + map((result) => { + if (result === null) { + return null; + } + return MapView.create(result as MapView); + }), + ).subscribe((mapView: MapView | null) => { + siblingRow = mapView; + this.configureSourceNaviagation(page, row_idx, selectedRow, siblingRow, task.id, params); + }); + } + else { + this.configureSourceNaviagation(page, row_idx, selectedRow, siblingRow, task.id, params); } }); } } + private configureSourceNaviagation(page: Page, row_idx: number, selectedRow: MapView, siblingRow: MapView | null, + task_id: string, params: ViewContext) { + const prev_row_idx = SourceNavigationService.prevUniqueRow(page.data, row_idx - 1, selectedRow.sourceIndex); + const next_row_idx = SourceNavigationService.nextUniqueRow(page.data, row_idx + 1, selectedRow.sourceIndex); + + const prevParams = {...params}; + const nextParams = {...params}; + const sourceNav: SourceNavSet = { + mapRow: selectedRow, + siblingRow: siblingRow, + current: { + page, + rowIndex: row_idx, + params + }, + previous: prev_row_idx !== null ? { + page, + rowIndex: prev_row_idx, + params: prevParams + } : null, + next: next_row_idx !== null ? { + page, + rowIndex: next_row_idx, + params: nextParams + } : null + }; + + // Find previous source - may be previous page + if (prev_row_idx === null && page.pageIndex > 0) { + prevParams.pageIndex = page.pageIndex - 1; + this.findAdjacentSource(task_id, prevParams, selectedRow.sourceIndex, 'PREVIOUS', sourceNav); + } + // Find next source - may be next page + if (next_row_idx === null && page.pageIndex < page.totalPages) { + nextParams.pageIndex = page.pageIndex + 1; + this.findAdjacentSource(task_id, nextParams, selectedRow.sourceIndex, 'NEXT', sourceNav); + } + + if (selectedRow) { + this.setSelectedRow(sourceNav); + } + } + } diff --git a/ui/snapclient/src/app/_services/source.service.ts b/ui/snapclient/src/app/_services/source.service.ts index 4fe66158..51a08b30 100644 --- a/ui/snapclient/src/app/_services/source.service.ts +++ b/ui/snapclient/src/app/_services/source.service.ts @@ -51,9 +51,11 @@ export class SourceService { /** List of sources * API is Set to return max page size of 1000 - TODO return all + * Update: increased limit to 10K in line with altering application.properties spring.data.rest.max-page-size=10000 for the user query + * but potentially still need paging if that limit is exceeded. */ fetchSources(): Observable { - const url = `${this.config.apiBaseUrl}/importedCodeSets?size=1000&sort=name,asc`; + const url = `${this.config.apiBaseUrl}/importedCodeSets?size=10000&sort=name,asc`; const header = ServiceUtils.getHTTPHeaders(); return this.http.get(url, header); } diff --git a/ui/snapclient/src/app/_services/target-changed.service.ts b/ui/snapclient/src/app/_services/target-changed.service.ts new file mode 100644 index 00000000..00204a18 --- /dev/null +++ b/ui/snapclient/src/app/_services/target-changed.service.ts @@ -0,0 +1,38 @@ +/* + * Copyright © 2023 SNOMED International + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { TargetRow } from '../_models/target_row'; + +@Injectable({ + providedIn: 'root' +}) +export class TargetChangedService { + + latestTarget: TargetRow = {}; + + private targetSubject$ = new BehaviorSubject(this.latestTarget); + targetChanged$ = this.targetSubject$.asObservable(); + + constructor() { } + + changeTarget(targetRow: TargetRow) { + this.latestTarget = targetRow; + this.targetSubject$.next(targetRow); + } + +} diff --git a/ui/snapclient/src/app/_services/user.service.ts b/ui/snapclient/src/app/_services/user.service.ts index 502b324f..b0acb114 100644 --- a/ui/snapclient/src/app/_services/user.service.ts +++ b/ui/snapclient/src/app/_services/user.service.ts @@ -50,7 +50,7 @@ export class UserService { getUsers(): Observable { // TODO this really should be handled by paging, or having the control that uses this data paged - return this.getUsersFromUrl(`${this.config.apiBaseUrl}/users?size=400`); + return this.getUsersFromUrl(`${this.config.apiBaseUrl}/users?size=10000`); } getUsersAssignedToTask(projectId: string): Observable { diff --git a/ui/snapclient/src/app/_utils/lastupdated_pipe.ts b/ui/snapclient/src/app/_utils/lastupdated_pipe.ts index be3ad777..8e23c7aa 100644 --- a/ui/snapclient/src/app/_utils/lastupdated_pipe.ts +++ b/ui/snapclient/src/app/_utils/lastupdated_pipe.ts @@ -22,7 +22,7 @@ const min = 1000 * 60; export class LastupdatedPipe implements PipeTransform { transform(datefield?: Date | null): string | null { if (!datefield) { - return datefield ?? null; + return null; } const today = new Date(); const diff = Math.floor(today.getTime() - new Date(datefield).getTime()); @@ -32,7 +32,7 @@ export class LastupdatedPipe implements PipeTransform { const months = Math.floor(days / 31); const years = Math.floor(days / 365.25); - let interval = []; + let interval: string[] = []; if (days == 0) { if (hours > 0) { interval.push(hours%24 + ' hours'); diff --git a/ui/snapclient/src/app/_utils/service_utils.ts b/ui/snapclient/src/app/_utils/service_utils.ts index 2d7d6b61..4cb88627 100644 --- a/ui/snapclient/src/app/_utils/service_utils.ts +++ b/ui/snapclient/src/app/_utils/service_utils.ts @@ -41,7 +41,7 @@ export class ServiceUtils { return { headers: new HttpHeaders({ 'Content-Type': 'application/json', - Accept: '*/*', + Accept: 'application/hal+json, application/json', }) }; } @@ -49,7 +49,7 @@ export class ServiceUtils { static getHTTPUploadHeaders(): { headers: HttpHeaders, params?: any } { return { headers: new HttpHeaders({ - Accept: '*/*', + Accept: 'application/json', }) }; } @@ -182,6 +182,9 @@ export class ServiceUtils { params = params.append('status', value.toString()); }); } + if (filterEntity.targetOutOfScope !== undefined) { + params = params.append('targetOutOfScope', filterEntity.targetOutOfScope.toString()); + } if (filterEntity.flagged !== undefined) { params = params.append('flagged', filterEntity.flagged.toString()); } @@ -194,6 +197,9 @@ export class ServiceUtils { if (filterEntity.assignedReviewer && filterEntity.assignedReviewer.length > 0) { params = params.append('assignedReviewer', filterEntity.assignedReviewer.toString()); } + if (filterEntity.assignedReconciler && filterEntity.assignedReconciler.length > 0) { + params = params.append('assignedReconciler', filterEntity.assignedReconciler.toString()); + } if (filterEntity.additionalColumns && filterEntity.additionalColumns.length > 0) { params = params.append('additionalColumns', filterEntity.additionalColumns.join(',')); } @@ -211,6 +217,9 @@ export class ServiceUtils { case 'noMap': mapViewFilter.noMap = typeof v === 'string' ? v.toUpperCase() === 'TRUE' : v[0].toUpperCase() === 'TRUE'; break; + case 'targetOutOfScope': + mapViewFilter.targetOutOfScope = typeof v === 'string' ? v.toUpperCase() === 'TRUE' : v[0].toUpperCase() === 'TRUE'; + break; case 'flagged': mapViewFilter.flagged = typeof v === 'string' ? v.toUpperCase() === 'TRUE' : v[0].toUpperCase() === 'TRUE'; break; @@ -241,6 +250,9 @@ export class ServiceUtils { case 'assignedReviewer': mapViewFilter.assignedReviewer = v; break; + case 'assignedReconciler': + mapViewFilter.assignedReconciler = v; + break; case "additionalColumns": if (v !== undefined) { if (Array.isArray(v)) { @@ -341,7 +353,7 @@ export class ServiceUtils { if (result) { return result[0]; } - return result; + return null; } // TODO: Maybe deprecate this as backend does the same thing diff --git a/ui/snapclient/src/app/_utils/status_utils.spec.ts b/ui/snapclient/src/app/_utils/status_utils.spec.ts index b8863ffa..5c9643f4 100644 --- a/ui/snapclient/src/app/_utils/status_utils.spec.ts +++ b/ui/snapclient/src/app/_utils/status_utils.spec.ts @@ -20,30 +20,30 @@ import {MapRowRelationship, MapRowStatus, MapView} from '../_models/map_row'; import {TaskType} from '../_models/task'; describe('StatusUtils', () => { - const unmappedSource = new MapView('', undefined, '1', 'abc', + const unmappedSource = new MapView('', undefined, '1', '1', 'abc', 'abc', undefined, undefined, undefined, MapRowStatus.UNMAPPED, - false, null, null, null, null, null, undefined, []); - const draftSource = new MapView('', undefined, '1', 'abc', + false, null, null, null, null, null, null, undefined, false, undefined, [], null); + const draftSource = new MapView('', undefined, '1', '1', 'abc', 'abc', '1234', 'target', MapRowRelationship.EQUIVALENT, MapRowStatus.DRAFT, - false, null, null, null, null, null, false, []); - const mappedSource = new MapView('', undefined, '1', 'abc', + false, null, null, null, null, null, null, false, false, undefined, [], null); + const mappedSource = new MapView('', undefined, '1', '1', 'abc', 'abc', '1234', 'target', MapRowRelationship.EQUIVALENT, MapRowStatus.MAPPED, - false, null, null, null, null, null, false, []); - const draftSource_noMap = new MapView('', undefined, '1', 'abc', + false, null, null, null, null, null, null, false, false, undefined, [], null); + const draftSource_noMap = new MapView('', undefined, '1', '1', 'abc', 'abc', undefined, undefined, undefined, MapRowStatus.DRAFT, - true, null, null, null, null, null, undefined, []); - const mappedSource_noMap = new MapView('', undefined, '1', 'abc', + true, null, null, null, null, null, null, undefined, false, undefined, [], null); + const mappedSource_noMap = new MapView('', undefined, '1', '1', 'abc', 'abc', undefined, undefined, undefined, MapRowStatus.MAPPED, - true, null, null, null, null, null, undefined, []); - const inreviewSource = new MapView('', undefined, '1', 'abc', + true, null, null, null, null, null, null, undefined, false, undefined, [], null); + const inreviewSource = new MapView('', undefined, '1', '1', 'abc', 'abc', '1234', 'target', MapRowRelationship.EQUIVALENT, MapRowStatus.INREVIEW, - false, null, null, null, null, null, false, []); - const acceptedSource = new MapView('', undefined, '1', 'abc', + false, null, null, null, null, null, null, false, false, undefined, [], null); + const acceptedSource = new MapView('', undefined, '1', '1', 'abc', 'abc', '1234', 'target', MapRowRelationship.EQUIVALENT, MapRowStatus.ACCEPTED, - false, null, null, null, null, null, false, []); - const rejectedSource = new MapView('', undefined, '1', 'abc', + false, null, null, null, null, null, null, false, false, undefined, [], null); + const rejectedSource = new MapView('', undefined, '1', '1', 'abc', 'abc', '1234', 'target', MapRowRelationship.EQUIVALENT, MapRowStatus.REJECTED, - false, null, null, null, null, null, false, []); + false, null, null, null, null, null, null, false, false, undefined, [], null); beforeEach(() => { TestBed.configureTestingModule({}); diff --git a/ui/snapclient/src/app/_utils/status_utils.ts b/ui/snapclient/src/app/_utils/status_utils.ts index e4ae0d31..fcc2c570 100644 --- a/ui/snapclient/src/app/_utils/status_utils.ts +++ b/ui/snapclient/src/app/_utils/status_utils.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -import {authorStatuses, MapRowStatus, mapRowStatuses, MapView, reviewStatuses} from '../_models/map_row'; +import {authorStatuses, MapRowStatus, mapRowStatuses, MapView, reviewStatuses, reconcileStatuses} from '../_models/map_row'; import {TaskType} from '../_models/task'; export class StatusUtils { @@ -27,6 +27,14 @@ export class StatusUtils { .includes(status); } + /** + * Under Reconciliation (Dual Mapping) + */ + static inReconcileState(status: MapRowStatus): boolean { + return reconcileStatuses.filter((s) => s !== MapRowStatus.MAPPED) + .includes(status); + } + /** * Under Review or Completed Review */ @@ -42,6 +50,11 @@ export class StatusUtils { static isStatusOptionDisabled(taskType: TaskType | string, mapView: MapView, statusOption: MapRowStatus): boolean { let disableStatus = false; switch (taskType) { + case TaskType.RECONCILE: + if (mapView.status === MapRowStatus.RECONCILE) { + disableStatus = true; + } + break; case TaskType.REVIEW: if (mapView.status === MapRowStatus.UNMAPPED || mapView.status === MapRowStatus.DRAFT) { disableStatus = true; @@ -76,6 +89,8 @@ export class StatusUtils { statusList = [MapRowStatus.UNMAPPED]; } else if (status === MapRowStatus.REJECTED) { statusList = authorStatuses.filter((m) => m !== MapRowStatus.UNMAPPED).concat([MapRowStatus.REJECTED]) + } else if (status === MapRowStatus.RECONCILE) { + statusList = [MapRowStatus.RECONCILE]; } else if (this.inAuthoredState(status)) { statusList = authorStatuses.filter((m) => m !== MapRowStatus.UNMAPPED); } else if (this.inReviewedState(status)) { @@ -91,6 +106,13 @@ export class StatusUtils { statusList = reviewStatuses; } break; + case TaskType.RECONCILE: + if (status === MapRowStatus.RECONCILE) { + statusList = reconcileStatuses; + } else if (status === MapRowStatus.MAPPED) { + statusList = [MapRowStatus.MAPPED]; + } + break; } return statusList; } diff --git a/ui/snapclient/src/app/_utils/write_disable_utils.ts b/ui/snapclient/src/app/_utils/write_disable_utils.ts index f7ce8937..c5c32b96 100644 --- a/ui/snapclient/src/app/_utils/write_disable_utils.ts +++ b/ui/snapclient/src/app/_utils/write_disable_utils.ts @@ -40,10 +40,13 @@ export class WriteDisableUtils { * Used to control when noMap and the row target are editable * @param task * @param status + * Note that this is used for both the details screen and the table screen, in situations where the behviour is different + * e.g. reconcile where reconcile is only allowed via the details screen, this will not represent the complete logic */ static isEditDisabled(taskType: TaskType | string | undefined, status: MapRowStatus | null | undefined): boolean { return taskType && status ? taskType as TaskType === TaskType.REVIEW || - StatusUtils.inReviewedState(status as MapRowStatus) : false; + StatusUtils.inReviewedState(status as MapRowStatus) || taskType as TaskType === TaskType.RECONCILE || + StatusUtils.inReconcileState(status as MapRowStatus) : false; } /** diff --git a/ui/snapclient/src/app/app.config.ts b/ui/snapclient/src/app/app.config.ts index 124ba8d6..4b01dc64 100644 --- a/ui/snapclient/src/app/app.config.ts +++ b/ui/snapclient/src/app/app.config.ts @@ -48,6 +48,7 @@ export interface AppConfig { termsOfServiceUrl: string; privacyPolicyUrl: string; currentTermsVersion: string; + identityProvider: string; } export let APP_CONFIG = new InjectionToken('APP_CONFIG'); diff --git a/ui/snapclient/src/app/automap/automap.component.ts b/ui/snapclient/src/app/automap/automap.component.ts index 7e90c8bf..421477ce 100644 --- a/ui/snapclient/src/app/automap/automap.component.ts +++ b/ui/snapclient/src/app/automap/automap.component.ts @@ -152,11 +152,11 @@ export class AutomapComponent implements OnInit { errorCount: context.fhirErrors + context.backendErrors, }).subscribe(msgs => { if (self.automapDialog && self.automapDialog.componentInstance) { - const errormsg = (context.fhirErrors || context.backendErrors) && ` ${msgs['AUTOMAP.AUTOMAP_ERRORS']}`; + const errormsg = (context.fhirErrors || context.backendErrors) ? ` ${msgs['AUTOMAP.AUTOMAP_ERRORS']}` : null; self.automapDialog.componentInstance.data = { ...self.automapDialog.componentInstance.data, message: msgs['AUTOMAP.AUTOMAP_COMPLETED'], - error: errormsg !== 0 ? errormsg.toString() : null + error: errormsg, }; } }); diff --git a/ui/snapclient/src/app/concept-properties/concept-properties.component.css b/ui/snapclient/src/app/concept-properties/concept-properties.component.css index 72bff9a2..5938ef08 100644 --- a/ui/snapclient/src/app/concept-properties/concept-properties.component.css +++ b/ui/snapclient/src/app/concept-properties/concept-properties.component.css @@ -1,5 +1,9 @@ .properties { overflow: auto; + max-height: 200px; +} + +.mat-card { max-height: 340px; } @@ -16,6 +20,10 @@ background-color: #00000010; } +.row-line-above { + border-top: 2px solid black; +} + .properties .mat-cell { padding: 3px 6px; } @@ -50,3 +58,8 @@ .properties-header { width: 150px; } + +/* make date role group "fixed width" as it only ever contains a circle (or not) */ +.mat-column-roleGroup { + width: 10px; +} diff --git a/ui/snapclient/src/app/concept-properties/concept-properties.component.html b/ui/snapclient/src/app/concept-properties/concept-properties.component.html index c18b0f00..cb1aa154 100644 --- a/ui/snapclient/src/app/concept-properties/concept-properties.component.html +++ b/ui/snapclient/src/app/concept-properties/concept-properties.component.html @@ -1,52 +1,112 @@ - - - - - - - - -
- - - - - {{'SEARCH.PROPERTY' | translate}} - - {{element.key}} - - - - - - - - {{ element.value[1] }} - - drag_indicator - - - - - - - {{'SEARCH.VALUE' | translate}} - - {{ element.value[0] }} - - - - - -
-
-
- - - + + + +
+ + + +
+ + + + + {{'SEARCH.PROPERTY' | translate}} + + {{element.key}} + + + + + + + + {{ element.value[1] }} + + drag_indicator + + + + + + + {{'SEARCH.VALUE' | translate}} + + {{ element.value[0] }} + + + + + +
+
+
+ + + +
+
+ +
+ + +
+ + + + + + +
+ +
+ +
+ + + + {{'SEARCH.PROPERTY' | translate}} + + {{element.key}} + + + + + + + + {{ element.value[1] }} + + drag_indicator + + + + + + + {{'SEARCH.VALUE' | translate}} + + {{ element.value[0] }} + + + + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/ui/snapclient/src/app/concept-properties/concept-properties.component.ts b/ui/snapclient/src/app/concept-properties/concept-properties.component.ts index 4d88b17a..e41628a1 100644 --- a/ui/snapclient/src/app/concept-properties/concept-properties.component.ts +++ b/ui/snapclient/src/app/concept-properties/concept-properties.component.ts @@ -20,9 +20,9 @@ import { TranslateService } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import {ErrorInfo} from '../errormessage/errormessage.component'; import { IAppState } from '../store/app.state'; -import { LookupConcept, LookupModule } from '../store/fhir-feature/fhir.actions'; +import { DisplayResolvedLookupConcept} from '../store/fhir-feature/fhir.actions'; import { Properties } from '../store/fhir-feature/fhir.effects'; -import { selectConceptProperties, selectModuleProperties } from '../store/fhir-feature/fhir.selectors'; +import { selectDisplayResolvedConceptProperties} from '../store/fhir-feature/fhir.selectors'; import { SelectionService } from '../_services/selection.service'; @Component({ @@ -44,6 +44,12 @@ export class ConceptPropertiesComponent implements OnInit, OnDestroy { key: string, value: any, }[] = []; + attributeRelationshipsView: { + firstValue: boolean, // used to indicate a line should be drawn to separate previous values + roleGroup: boolean, // used to indicate this is the first element in a role group + key: string, + value: any, + }[] = []; error: ErrorInfo = {}; displayedColumns = [ @@ -52,6 +58,13 @@ export class ConceptPropertiesComponent implements OnInit, OnDestroy { 'value', ]; + displayedAttributeRelationshipColumns = [ + 'roleGroup', // this is where the role group symbol sits + 'key', + 'aux', + 'value', + ]; + private displayedProps = [ 'code', 'Fully specified name', @@ -64,6 +77,12 @@ export class ConceptPropertiesComponent implements OnInit, OnDestroy { 'effectiveTime', 'moduleId', ]; + + private displayedAttributeRelationshipProps = [ + 'parent', + 'attributeRelationships' + ]; + private subscription = new Subscription(); constructor( @@ -82,10 +101,11 @@ export class ConceptPropertiesComponent implements OnInit, OnDestroy { self.display = selection.display; self.system = selection.system; self.selectionVersion = selection.version; - self.store.dispatch(new LookupConcept({ + self.store.dispatch(new DisplayResolvedLookupConcept({ code: selection.code, system: selection.system, version: self.selectionVersion ?? self.version, + properties: ["*"] })); } }, @@ -93,38 +113,16 @@ export class ConceptPropertiesComponent implements OnInit, OnDestroy { complete(): void {} })); - self.subscription.add(self.store.select(selectModuleProperties).subscribe( - (props) => { - if (props) { - // replace module id with preferred term - var foundIndex = this.propertiesView.findIndex(x => x.key == 'module'); - if (foundIndex > -1) { - this.propertiesView[foundIndex] = { key: 'module', value: [props['display'][0][0]] }; - // trigger angular change detection - this.propertiesView = [...this.propertiesView]; - } - } - }, - (_error) => this.translate.get('ERROR.MODULE_LOOKUP').subscribe((res) => this.error.message = res) - )); - - self.subscription.add(self.store.select(selectConceptProperties).subscribe( + self.subscription.add(self.store.select(selectDisplayResolvedConceptProperties).subscribe( (props) => { self.propertiesView = []; + self.attributeRelationshipsView = []; if (props) { - this.displayedProps.forEach(p => { + this.displayedProps.concat(this.displayedAttributeRelationshipProps).forEach(p => { props[p]?.forEach(v => { if (p === 'Fully specified name') { self.display = v[0]; } - if (p === 'moduleId') { - // run a separate query to get the module label now that we know the module id - self.store.dispatch(new LookupModule({ - code: v[0], - system: self.system, - version: self.selectionVersion ?? self.version, - })); - } switch ( p ) { case "display": @@ -135,11 +133,37 @@ export class ConceptPropertiesComponent implements OnInit, OnDestroy { // SNOMED-451: "inactive" to read "active" this.propertiesView.push({ key: "active", value: [!v[0]]}) break; - case "moduleId": - // SNOMED-451: display PT rather than id - // moduleId gets replaced with PT when selectModuleProperties - this.propertiesView.push({ key: "module", value: v}) + case "attributeRelationships": + + let attributeCode : string = ""; + let attributeValue : string = ""; + let isFirst = true; + + if (v[0] instanceof Array) { + v.forEach((subproperty: { name: string; valueCode: any; valueString: string}[]) => { + if (subproperty instanceof Array) { + ({attributeCode, attributeValue} = this.processSubproperty(subproperty)); + } + + let displayRoleGroup = false; + if (isFirst) { + displayRoleGroup = true; + isFirst = false; + } + this.attributeRelationshipsView.push({firstValue: displayRoleGroup, roleGroup: displayRoleGroup, key: attributeCode, value: [attributeValue]}) + }); + } + else { + ({attributeCode, attributeValue} = this.processSubproperty(v)); + // SNOMED CT diagramming guidelines specify ungrouped attributes should be displayed before grouped attributes + this.attributeRelationshipsView.unshift({firstValue: true, roleGroup: false, key: attributeCode, value: [attributeValue]}) + } + break; + // Removed 28/02/23 due to duplication with tree view and space limitations + // case "parent": + // this.attributeRelationshipView.push({firstValue: false, roleGroup: false, key: "parent", value: v}); + // break; default: this.propertiesView.push({ key: p, value: v }); break; @@ -147,13 +171,58 @@ export class ConceptPropertiesComponent implements OnInit, OnDestroy { }); }); + // first entry does not have a line above it to separate attributes + if (this.attributeRelationshipsView.length > 0) { + this.attributeRelationshipsView[0]['firstValue'] = false; + } + } }, (_error) => this.translate.get('ERROR.CONCEPT_LOOKUP').subscribe((res) => this.error.message = res) )); self.propertiesView = []; + self.attributeRelationshipsView = []; + + } + + private processSubproperty(subproperty: { name: string; valueCode: any; valueString: string; }[]) { + let attributeCode : string = ""; + let attributeValue : string = ""; + subproperty.forEach((part: { name: string; valueCode: any; valueString: string}) => { + if ('code' === part.name && part.hasOwnProperty('valueString')) { + if (part.hasOwnProperty('valueString')) { + attributeCode = part.valueString; + } + else { + attributeCode = part.valueCode; + } + } else if ('value' === part.name || 'valueCode' === part.name) { + if (part.hasOwnProperty('valueString')) { + attributeValue = part.valueString; + } + else { + attributeValue = part.valueCode; + } + } else if (part.name.startsWith('value')) { + let valueName; + type ObjectKey = keyof typeof part; + + Object.getOwnPropertyNames(part).map(objectName => { + if (objectName.startsWith('value')) { + valueName = objectName as ObjectKey; + } + + }) + if (valueName) { + attributeValue = part[valueName]; + } + + } + + }); + return {attributeCode: attributeCode, attributeValue: attributeValue}; } ngOnDestroy(): void { diff --git a/ui/snapclient/src/app/errorhandler/snap2snomederrorhandler.ts b/ui/snapclient/src/app/errorhandler/snap2snomederrorhandler.ts index 372e3c3f..7bebe9be 100644 --- a/ui/snapclient/src/app/errorhandler/snap2snomederrorhandler.ts +++ b/ui/snapclient/src/app/errorhandler/snap2snomederrorhandler.ts @@ -98,6 +98,7 @@ export class Snap2SnomedErrorHandler implements ErrorHandler { } protected backendError(backendUrl: string | null, errorStatus: number): string { +console.log('backendError', backendUrl, errorStatus); const matchedUrls = this.errorMessageMap.filter((error) => { return backendUrl?.indexOf(error.url) !== -1; }); @@ -148,7 +149,7 @@ export class Snap2SnomedErrorHandler implements ErrorHandler { // Log error to sentry if (error instanceof HttpErrorResponse) { // Don't report 4XX errors to Sentry - if (false && error.status >= 400 && error.status < 500 ) { // temporarily log all - lawley & loi + if (error.status >= 400 && error.status < 500 ) { return; } } diff --git a/ui/snapclient/src/app/footer/footer.component.html b/ui/snapclient/src/app/footer/footer.component.html index 5642e71a..3fb704d9 100644 --- a/ui/snapclient/src/app/footer/footer.component.html +++ b/ui/snapclient/src/app/footer/footer.component.html @@ -1,10 +1,12 @@ diff --git a/ui/snapclient/src/app/footer/footer.component.spec.ts b/ui/snapclient/src/app/footer/footer.component.spec.ts index c8b15a47..68d73ec5 100644 --- a/ui/snapclient/src/app/footer/footer.component.spec.ts +++ b/ui/snapclient/src/app/footer/footer.component.spec.ts @@ -21,6 +21,9 @@ import {DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; import {APP_CONFIG} from "../app.config"; import {MAT_DIALOG_DATA, MatDialogModule} from "@angular/material/dialog"; +import {TranslateLoader, TranslateModule, TranslateService} from '@ngx-translate/core'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { HttpLoaderFactory } from '../app.module'; describe('FooterComponent', () => { let component: FooterComponent; @@ -29,10 +32,18 @@ describe('FooterComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ - MatDialogModule + MatDialogModule, + HttpClientTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useFactory: HttpLoaderFactory, + deps: [HttpClientTestingModule] + } + }) ], providers: [ - { provide: APP_CONFIG, useValue: {} } + TranslateService, { provide: APP_CONFIG, useValue: {} } ], declarations: [FooterComponent] }) @@ -51,9 +62,8 @@ describe('FooterComponent', () => { it('should have footer links', () => { const el: DebugElement = fixture.debugElement.query(By.css('.footer-link')); - const expectedLabel = 'Copyright 2022 SNOMED International'; const expectedUrl = 'http://www.snomed.org'; - expect(el.nativeElement.textContent).toBe(expectedLabel); + expect(el.nativeElement.textContent).toBe('FOOTER.COPYRIGHT_SNOMED_INTERNATIONAL'); expect(el.nativeElement.getAttribute('href')).toBe(expectedUrl); }); }); diff --git a/ui/snapclient/src/app/footer/footer.component.ts b/ui/snapclient/src/app/footer/footer.component.ts index 131376a8..fb2e3e02 100644 --- a/ui/snapclient/src/app/footer/footer.component.ts +++ b/ui/snapclient/src/app/footer/footer.component.ts @@ -46,5 +46,9 @@ export class FooterComponent implements OnInit { }); return false; } + + openUserGuide(): void { + window.open(this.config.userGuideUrl, '_blank'); + } } diff --git a/ui/snapclient/src/app/mapping/bulkchange/bulkchange.component.html b/ui/snapclient/src/app/mapping/bulkchange/bulkchange.component.html index 906d81c7..c3c0d0ae 100644 --- a/ui/snapclient/src/app/mapping/bulkchange/bulkchange.component.html +++ b/ui/snapclient/src/app/mapping/bulkchange/bulkchange.component.html @@ -9,16 +9,21 @@

warning
{{'BULKCHANGEDIALOG.OWNER_WARNING' | translate}}
+
+
+ info +
{{'BULKCHANGEDIALOG.RECONCILE_STATUS_INFO' | translate}}
+
+ *ngIf="((data.task && data.task.type !== 'REVIEW') || data.task === null) && !clearTarget && noMapValue == null && changedStatus == null && !isDualMapView()"> {{'BULKCHANGEDIALOG.SELECTED_TARGET' | translate}} {{currentSelection?.display || 'BULKCHANGEDIALOG.NO_SELECTED_TARGET' | translate}} + *ngIf="((data.task && data.task.type !== 'REVIEW') || data.task === null) && !clearTarget && noMapValue == null && changedStatus == null && !isDualMapView()"> {{'TABLE.RELATIONSHIP' | translate}} -- @@ -28,7 +33,7 @@

- + {{'TABLE.STATUS' | translate}} -- @@ -38,8 +43,8 @@

-
{{'BULKCHANGEDIALOG.STATUS_AUTO' | translate}}
- +
{{'BULKCHANGEDIALOG.STATUS_AUTO' | translate}}
+
{{'BULKCHANGEDIALOG.NOMAP_WARNING' | translate}}
- +
{{'BULKCHANGEDIALOG.CLEAR_TARGET' | translate}}
+ +
+ + {{'BULKCHANGEDIALOG.REDO_DUAL_MAPPING' | translate}} +
+
{{ 'MAP.TARGET_SCOPE_COMMON' | translate }} - + {{ 'MAP.ECL_ANY' | translate }} {{ 'MAP.ECL_FINDINGS' | translate }} {{ 'MAP.ECL_PROCEDURES' | translate }} @@ -100,26 +107,26 @@ {{ 'MAP.TARGET_SCOPE_ERROR' | translate }} + *ngIf="formGroup.controls.toScope.invalid || formGroup.controls.toScope.hasError('maxlength')">{{ 'MAP.TARGET_SCOPE_ERROR' | translate }} {{ 'MAP.ECL_HELP' | translate }} - {{toscope.value?.length || 0}}/{{MAX_TARGETSCOPE}} + {{mappingModel.toScope?.length || 0}}/{{MAX_TARGETSCOPE}} -
+
Selected Mapping File:
{{mappingFile?.source?.source_file?.name}} - cancel
@@ -156,7 +163,7 @@

{{'PROJECT.PROJECT_ROLES' | translate}}

-
+
info
@@ -166,10 +173,14 @@

{{'PROJECT.PROJECT_ROLES' | translate}}

info
- - +
+ info +
+
+ + diff --git a/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.spec.ts b/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.spec.ts index 7d7c0991..fd8ce424 100644 --- a/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.spec.ts +++ b/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.spec.ts @@ -69,6 +69,7 @@ describe('MappingAddComponent', () => { mapping.project = new Project(); mapping.project.title = 'TEST'; mapping.project.id = '1'; + mapping.project.dualMapMode = false; mapping.mapVersion = '1.0'; mapping.source.id = '1'; mapping.toVersion = 'target'; @@ -156,16 +157,16 @@ describe('MappingAddComponent', () => { it('form should initially be invalid', () => { component.ngOnInit(); fixture.detectChanges(); - expect(component.form).toBeTruthy(); - expect(component.form?.invalid).toBeTruthy(); + expect(component.formGroup).toBeTruthy(); + expect(component.formGroup?.invalid).toBeTruthy(); }); it('form should be valid with all fields', () => { component.mappingModel = mapping; fixture.detectChanges(); - expect(component.form).toBeTruthy(); + expect(component.formGroup).toBeTruthy(); fixture.whenStable().then(() => { - expect(component.form?.valid).toBeTruthy(); + expect(component.formGroup?.valid).toBeTruthy(); }); }); }); diff --git a/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.ts b/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.ts index 3fbcf044..a952a204 100644 --- a/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-add/mapping-add.component.ts @@ -40,7 +40,7 @@ import {Source} from 'src/app/_models/source'; import {FhirService, Release} from 'src/app/_services/fhir.service'; import {LoadReleases} from 'src/app/store/fhir-feature/fhir.actions'; import {selectFhirError, selectReleaseList} from 'src/app/store/fhir-feature/fhir.selectors'; -import {NgForm} from '@angular/forms'; +import {FormControl, FormGroup, Validators} from '@angular/forms'; import {cloneDeep} from 'lodash'; import {ErrorInfo} from 'src/app/errormessage/errormessage.component'; import {FormUtils} from '../../_utils/form_utils'; @@ -65,7 +65,6 @@ export class MappingAddComponent implements OnInit { existingMapVersions: string[] | null = null; loading = false; selectedEdition : string = ""; - @ViewChild('myForm') form: NgForm | undefined; mappingFile: ImportMappingFileParams | undefined | null; MAX_TITLE = FormUtils.MAX_TITLE; @@ -78,13 +77,25 @@ export class MappingAddComponent implements OnInit { previousVersionSource: Source | undefined; warnDelete = false; - @Input() set mapping(value: Mapping) { + formGroup: FormGroup = new FormGroup({ + title: new FormControl(''), + mapVersion: new FormControl(''), + description: new FormControl(''), + sourceId: new FormControl(''), + toEdition: new FormControl(''), + toVersion: new FormControl(''), + toScopeSelect: new FormControl(''), + toScope: new FormControl('', [Validators.minLength(1)]), + dualMapMode: new FormControl('') + }); + + @Input() set mapping(value: Mapping | undefined) { if (value) { this.mappingModel = cloneDeep(value); // if target version no longer available - need to clear model if (!this.hasAvailableTargetVersion(this.mappingModel.toVersion) && this.editionToVersionsMapLoaded) { - this.mappingModel.toVersion = ''; - this.mappingModel.toScope = ''; + this.formGroup.controls.toVersion.setValue(''); + this.formGroup.controls.toScope.setValue(''); } else if (this.editionToVersionsMapLoaded && this.editionToVersionsMap) { // initialize to version (country and date) @@ -128,9 +139,70 @@ export class MappingAddComponent implements OnInit { ngOnInit(): void { const self = this; self.error = {}; - self.loadReleases(); - self.store.dispatch(new LoadSources()); self.load(); + + self.formGroup.controls.title.setValue(this.mappingModel.project.title); + if (self.mode === 'FORM.COPY') { + self.formGroup.controls.title.disable() + } + self.formGroup.controls.mapVersion.setValue(this.mappingModel.mapVersion); + self.formGroup.controls.description.setValue(this.mappingModel.project.description); + if (self.mode === 'FORM.VIEW' || self.mode === 'FORM.COPY') { + self.formGroup.controls.description.disable() + } + self.formGroup.controls.sourceId.setValue(this.mappingModel.source.id); + self.formGroup.controls.toEdition.setValue(this.selectedEdition); + self.formGroup.controls.toVersion.setValue(this.mappingModel.toVersion); + self.formGroup.controls.toScopeSelect.setValue(this.mappingModel.toScope); + self.formGroup.controls.toScope.setValue(this.mappingModel.toScope); + self.formGroup.controls.dualMapMode.setValue(this.mappingModel.project.dualMapMode); + + self.formGroup.controls.title.valueChanges.subscribe((value) => { + if (self.mappingModel.project.title !== value) { + self.mappingModel.project.title = value; + } + }); + self.formGroup.controls.mapVersion.valueChanges.subscribe((value) => { + if (self.mappingModel.mapVersion !== value) { + self.mappingModel.mapVersion = value; + } + }); + self.formGroup.controls.description.valueChanges.subscribe((value) => { + self.mappingModel.project.description = value; + }); + self.formGroup.controls.sourceId.valueChanges.subscribe((value) => { + if (self.mappingModel.source.id !== value) { + self.mappingModel.source.id = value; + } + }); + self.formGroup.controls.toEdition.valueChanges.subscribe((value) => { + self.selectedEdition = value; + self.changeEdition(value); + }); + self.formGroup.controls.toVersion.valueChanges.subscribe((value) => { + self.mappingModel.toVersion = value; + }); + self.formGroup.controls.toScopeSelect.valueChanges.subscribe((value) => { + self.mappingModel.toScope = value; + if (value !== self.formGroup.controls.toScope.value) { + self.formGroup.controls.toScope.setValue(value); + } + }); + self.formGroup.controls.toScope.valueChanges.subscribe((value) => { + self.mappingModel.toScope = value; + if (value !== self.formGroup.controls.toScopeSelect.value) { + self.formGroup.controls.toScopeSelect.setValue(value); + } + }); + self.formGroup.controls.dualMapMode.valueChanges.subscribe((value) => { + if (self.mappingModel.project.dualMapMode !== value) { + self.mappingModel.project.dualMapMode = value; + } + if (self.mappingModel.project.dualMapMode) { + // import is not allowed in dual map mode + this.mappingFile = undefined; + } + }); } ngOnChanges(changes: SimpleChanges): void { @@ -156,6 +228,10 @@ export class MappingAddComponent implements OnInit { private load(): void { const self = this; + + self.loadReleases(); + self.loadSources(); + self.store.select(selectMappingLoading).subscribe((res) => this.loading = res); self.store.select(selectMappingFile).subscribe((res) => this.mappingFile = res); self.store.select(selectMappingError).subscribe((error) => { @@ -188,7 +264,7 @@ export class MappingAddComponent implements OnInit { }); self.store.select(selectAddEditMappingSuccess).subscribe(res => { if (res && !self.error.detail) { - this.closed.emit(); + //this.closed.emit(); } }); } @@ -201,7 +277,8 @@ export class MappingAddComponent implements OnInit { if (this.mode === 'FORM.CREATE') { this.store.dispatch(new AddMapping({ mapping: this.mappingModel, - importFile: this.mappingFile + importFile: this.mappingFile, + dualMapMode: this.mappingModel.project.dualMapMode })); } else if (this.mode === 'FORM.EDIT') { this.store.dispatch(new UpdateMapping(this.mappingModel)); @@ -230,13 +307,14 @@ export class MappingAddComponent implements OnInit { ); } - onCancel($event: Event, form: NgForm): void { + onCancel($event: Event): void { $event.preventDefault(); this.warnDelete = false; this.closed.emit(); this.error = {}; } + addSource($event: MouseEvent): void { $event.preventDefault(); this.store.dispatch(new InitSelectedSource()); @@ -259,6 +337,7 @@ export class MappingAddComponent implements OnInit { this.store.select(selectSourceState).subscribe((state) => { this.sources = state.sources; this.mappingModel.source = state.selectedSource ?? new Source(); + this.formGroup.controls.sourceId.setValue(this.mappingModel.source.id); }); } }); @@ -268,6 +347,10 @@ export class MappingAddComponent implements OnInit { this.store.dispatch(new LoadReleases()); } + loadSources(): void { + this.store.dispatch(new LoadSources()); + } + getFormModeTextForTranslation(): string { if (this.mode === 'FORM.VIEW') { return 'MAP.MAP_VIEW_DETAILS'; @@ -282,7 +365,7 @@ export class MappingAddComponent implements OnInit { } } - onImportMapping($event: Event, form: NgForm): void { + onImportMapping($event: Event): void { $event.preventDefault(); const dialogRef = this.dialog.open(MappingImportComponent, { width: this.width, data: { @@ -299,7 +382,7 @@ export class MappingAddComponent implements OnInit { }); } - onClearSelectedMappingFile($event: Event, form: NgForm): void { + onClearSelectedMappingFile($event: Event): void { this.mappingFile = undefined; } @@ -313,13 +396,13 @@ export class MappingAddComponent implements OnInit { } } - changeEdition($event: MatSelectChange) { - this.mappingModel.toVersion = ""; // reset - let versions = this.editionToVersionsMap?.get($event.value); + changeEdition(value: string) { + this.formGroup.controls.toVersion.setValue(''); // reset + const versions = this.editionToVersionsMap?.get(value); if (versions) { this.editionVersions = versions; // select the most recent (or only, if just 1) version - this.mappingModel.toVersion = this.editionVersions[0].uri; + this.formGroup.controls.toVersion.setValue(versions[0].uri); } } diff --git a/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.css b/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.css index 77bb6333..f53f59a6 100644 --- a/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.css +++ b/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.css @@ -59,7 +59,7 @@ h2 { .container-row { display: flex; flex-direction: row; - flex-wrap: wrap; + /* flex-wrap: wrap; this causes properties / attributes to wrap to the next row which is not desirable */ } .card-col { @@ -78,7 +78,7 @@ h2 { .tree-view { flex: 2; - height: 500px; + /* height: 500px; */ position: relative; } @@ -89,6 +89,9 @@ h2 { z-index: 10; } +.tab-view { + flex: 1; +} .properties-view { flex: 1; } @@ -238,3 +241,7 @@ h2 { } } +#targetLastAuthorUserChip { + margin-right: 0.5em; +} + diff --git a/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.html b/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.html index ce4f038f..685e1684 100644 --- a/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.html +++ b/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.html @@ -13,7 +13,7 @@

{{'SOURCE.SOURCE' | translate}}: {{source.display}}

@@ -31,125 +31,118 @@

{{'SOURCE.SOURCE' | translate}}: {{source.display}}
- +
{{source.additionalColumnNames && source.additionalColumnNames[idx] ? source.additionalColumnNames[idx] : ''}}{{source.additionalColumnNames && source.additionalColumnNames[idx] ? + source.additionalColumnNames[idx] : ''}} {{additionalCol}}
+ [(ngModel)]="source.status"> {{'STATUS.' + status | translate}} + [value]="status" + (click)="updateStatus2(status)"> + {{'STATUS.' + status | translate}} - - -
-
- {{'MAP.MAP' | translate}}: {{task.mapping.project.title}} -

{{task.type | translate}}

-
+ +

-
- - {{filter | translate}} - +
+ {{'MAP.MAP' | translate}}: {{task.mapping.project.title}} +

{{task.type | translate}}

- - - -
-
-
- - {{'MAP.SEARCH_TARGET' | translate}} - +
+
+ + {{filter | translate}} + +
+ + + +
+
+
+ + {{'MAP.SEARCH_TARGET' | translate}} + +
+
+ +
+
-
- -
- -
-
- - - - -
+
+ + + +
+
{{'DETAILS.TARGET_BY_RELATIONSHIP' | translate}} - + {{'TABLE.NO_MAP' | translate}} - - - - - - {{toIconName(status)}} - - - + + + + + {{toIconName(status)}} + + +
-
- - {{'DETAILS.TARGET_PROPERTIES' | translate}} - -
+ +
- +
-
- +
\ No newline at end of file diff --git a/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.ts b/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.ts index 94fcbf4f..e3cf6f12 100644 --- a/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-detail/mapping-detail.component.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,13 +22,12 @@ import {Task, TaskType} from '../../_models/task'; import {ErrorInfo} from '../../errormessage/errormessage.component'; import {MapService} from '../../_services/map.service'; import { - AdditionalColumn, - authorStatuses, MapRow, MapRowStatus, mapRowStatusToIconName, MapView, MapViewFilter, + TARGET_OUT_OF_SCOPE_TAG, toMapRowStatus } from '../../_models/map_row'; import {TargetRow} from '../../_models/target_row'; @@ -50,8 +49,7 @@ import {StatusUtils} from '../../_utils/status_utils'; import {WriteDisableUtils} from "../../_utils/write_disable_utils"; import {debounceTime} from "rxjs/operators"; import {User} from "../../_models/user"; -import { AdditionalColumnValue } from 'src/app/_models/source'; - +import { TargetChangedService } from 'src/app/_services/target-changed.service'; export type SourceRow = { id: string; @@ -72,7 +70,7 @@ export type SourceRow = { export class MappingDetailComponent implements OnInit, OnDestroy { private subscription = new Subscription(); - private selectedRowset: SourceNavSet | null = null; + public selectedRowset: SourceNavSet | null = null; source!: SourceRow; nodes: ConceptNode[] = []; loadingHierarchy = false; @@ -106,7 +104,8 @@ export class MappingDetailComponent implements OnInit, OnDestroy { private selectionService: SelectionService, private mapService: MapService, private sourceNavigation: SourceNavigationService, - public dialog: MatDialog) { + public dialog: MatDialog, + private targetChangedService: TargetChangedService) { this.translate.get('DIALOG.OK').subscribe((msg) => this.savedOK = msg); this.translate.get('DIALOG.CANCEL').subscribe((msg) => this.cancel = msg); this.translate.get('DIALOG.TITLE_CONFIRM').subscribe((msg) => this.confirmTitle = msg); @@ -208,6 +207,42 @@ export class MappingDetailComponent implements OnInit, OnDestroy { this.selectionService.select(node); } + private isDualMapMode() : boolean { + if (this.task) { + return this.task.mapping.project.dualMapMode; + } + return false; + } + + isReconcileTask() : boolean { + if (this.task?.type === TaskType.RECONCILE) { + return true; + } + return false; + } + + isNoMapChecked() : boolean { + if (this.isDualMapMode() && this.selectedRowset?.siblingRow) { + return this.source.noMap || this.selectedRowset.siblingRow.noMap; + } + + return this.source.noMap; + } + + getNoMapAuthor() : User { + + let author : User = new User(); + + if (this.source.noMap && this.selectedRowset?.mapRow?.lastAuthor) { + author = this.selectedRowset?.mapRow?.lastAuthor; + } + else if (this.selectedRowset?.siblingRow?.noMap && this.selectedRowset?.siblingRow?.lastAuthor) { + author = this.selectedRowset.siblingRow.lastAuthor; + } + + return author; + } + private updateChips(): void { const self = this; @@ -260,6 +295,7 @@ export class MappingDetailComponent implements OnInit, OnDestroy { backToTask(): void { this.clearLoading(); this.nodes = []; + this.error.message = undefined; this.detailClose.emit(true); } @@ -269,16 +305,18 @@ export class MappingDetailComponent implements OnInit, OnDestroy { if (self.task?.mapping?.id && self.source) { self.rowId = self.source.id ?? null; // self.noMap = self.source.noMap; - self.mapService.findTargetsBySourceIndex(self.task.mapping.id, self.source.index).pipe(debounceTime(200)).subscribe((rows) => { + + self.mapService.findTargetsBySourceIndex(self.task.mapping.id, self.source.index, self.task).pipe(debounceTime(200)).subscribe((rows) => { const source = self.source; - if (source && !source.noMap && rows._embedded.mapRowTargets.length > 0) { + if (source && rows._embedded.mapRowTargets.length > 0) { self.mapRows = rows._embedded.mapRowTargets.map((target) => { const status = target.row?.status ?? MapRowStatus.UNMAPPED; const flagged = target.row?.id ? target.flagged : undefined; - return new MapView(target.row?.id || '', target.id, source.index || '', source.code || '', + const targetOutOfScope = target.tags ? target.tags.includes(TARGET_OUT_OF_SCOPE_TAG) : undefined; + return new MapView(target.row?.id || '', target.id, source.id, source.index || '', source.code || '', source.display || '', target.targetCode, target.targetDisplay, target.relationship, status, - false, target.row?.latestNote || null, null, null, null, - null, flagged, source.additionalColumnValues); + false, target.row?.latestNote || null, null, null, null, null, + null, flagged, targetOutOfScope, target.tags, source.additionalColumnValues, target.lastAuthor); }); } else { self.mapRows = []; @@ -296,14 +334,18 @@ export class MappingDetailComponent implements OnInit, OnDestroy { const targetRow = new TargetRow( mapView.rowId, mapView.targetId, mapView.targetCode, mapView.targetDisplay, - mapView.relationship, mapView.flagged); + mapView.relationship, mapView.flagged, mapView.targetOutOfScope, + mapView.targetOutOfScope ? [TARGET_OUT_OF_SCOPE_TAG]: undefined, this.task?.type); this.mapService.createTarget(targetRow).subscribe((result) => { const targetId = ServiceUtils.extractIdFromHref(result._links.self.href, null); mapView.updateNoMap(mapView.noMap); mapView.targetId = targetId; self.loadTargets(); self.updateSelectedRowSet(true); - self.updateStatus(mapView.status as MapRowStatus); + if (!(this.isReconcileTask() && mapView.status == MapRowStatus.DRAFT)) { + self.updateStatus(mapView.status as MapRowStatus); + } + this.targetChangedService.changeTarget(targetRow); }, (err) => this.translate.get('ERROR.TARGETS_NOT_SAVED').subscribe((msg) => { this.error.message = msg; @@ -319,11 +361,14 @@ export class MappingDetailComponent implements OnInit, OnDestroy { this.mapService.deleteTarget(mapView.targetId).subscribe((result) => { self.mapRows = self.mapRows.map((r) => r).filter((row) => row !== mapView); self.updateSelectedRowSet(false); - if (self.mapRows.length <= 0) { - self.updateStatus(MapRowStatus.UNMAPPED); - } else { - self.updateStatus(MapRowStatus.DRAFT); + if (!this.isReconcileTask()) { + if (self.mapRows.length <= 0) { + self.updateStatus(MapRowStatus.UNMAPPED); + } else { + self.updateStatus(MapRowStatus.DRAFT); + } } + this.targetChangedService.changeTarget({}); }, (err) => this.translate.get('ERROR.TARGETS_NOT_DELETED').subscribe((msg) => { this.error.message = msg; @@ -332,6 +377,49 @@ export class MappingDetailComponent implements OnInit, OnDestroy { } } + isEditDisabled() : boolean { + return ((!this.isReconcileTask() && this.writeDisableUtils.isEditDisabled(this.task?.type, toMapRowStatus(this.source.status))) + || (this.isReconcileTask() && toMapRowStatus(this.source.status) === MapRowStatus.MAPPED)); + } + + canSaveTarget($event: MapRowStatus) : boolean { + const self = this; + if (self.task?.type == TaskType.RECONCILE && $event == MapRowStatus.MAPPED) { + + if (this.isNoMapChecked() && (this.selectedRowset?.mapRow?.targetCode || this.selectedRowset?.siblingRow?.targetCode)) { + self.translate.get('ERROR.RECONCILE_NO_MAP_AND_TARGETS').subscribe((res: any) => { + self.error.message = res; + }); + self.source.status = MapRowStatus.RECONCILE; + return false; + } + else if (!this.isNoMapChecked() && !this.selectedRowset?.mapRow?.targetCode && !this.selectedRowset?.siblingRow?.targetCode) { + self.translate.get('ERROR.RECONCILE_NO_NO_MAP_OR_TARGETS').subscribe((res: any) => { + self.error.message = res; + }); + self.source.status = MapRowStatus.RECONCILE; + return false; + } + else if ((this.selectedRowset?.mapRow?.targetCode === this.selectedRowset?.siblingRow?.targetCode) + && (this.selectedRowset?.mapRow?.relationship !== this.selectedRowset?.siblingRow?.relationship)) { + self.translate.get('ERROR.RECONCILE_SAME_TARGET_MULTIPLE_RELATIONSHIPS').subscribe((res: any) => { + self.error.message = res; + }); + self.source.status = MapRowStatus.RECONCILE; + return false; + } + else if (this.selectedRowset?.mapRow?.targetCode && (this.selectedRowset?.mapRow?.targetCode === this.selectedRowset?.siblingRow?.targetCode) + && (this.selectedRowset?.mapRow?.relationship === this.selectedRowset?.siblingRow?.relationship)) { + self.translate.get('ERROR.RECONCILE_DUPLICATE_TARGET').subscribe((res: any) => { + self.error.message = res; + }); + self.source.status = MapRowStatus.RECONCILE; + return false; + } + } + return true; + } + updateNoMap($event: MatCheckboxChange): void { const self = this; const cbNoMap = $event.checked; @@ -351,7 +439,7 @@ export class MappingDetailComponent implements OnInit, OnDestroy { if (ok) { if (self.rowId) { // remove any saved targets - will be deleted by API when No Map set to true - self.mapService.updateNoMap(self.rowId, cbNoMap).subscribe((result) => { + self.mapService.updateNoMap(self.rowId, cbNoMap, self.task?.type == TaskType.RECONCILE).subscribe((result) => { self.source.noMap = cbNoMap; self.mapRows = []; self.source.status = result.status; @@ -370,8 +458,19 @@ export class MappingDetailComponent implements OnInit, OnDestroy { } }); } else if (self.rowId) { - self.mapService.updateNoMap(self.rowId, cbNoMap).subscribe((result) => { + + let rowId = self.rowId; + if (self.isDualMapMode() && self.selectedRowset?.siblingRow) { + if (self.selectedRowset.siblingRow.noMap != cbNoMap) { + rowId = self.selectedRowset?.siblingRow.rowId; + } + } + + self.mapService.updateNoMap(rowId, cbNoMap, self.task?.type == TaskType.RECONCILE).subscribe((result) => { self.source.noMap = cbNoMap; + if (self.isDualMapMode() && self.selectedRowset?.siblingRow) { + self.selectedRowset.siblingRow.noMap = cbNoMap; + } self.source.status = result.status; if (self.selectedRowset?.mapRow) { self.selectedRowset.mapRow.updateFromRow(result as MapRow); @@ -387,14 +486,16 @@ export class MappingDetailComponent implements OnInit, OnDestroy { loadPrevious(): void { const self = this; if (self.task && self.selectedRowset?.previous) { - self.sourceNavigation.select(self.task.id, self.selectedRowset?.previous); + this.error.message = undefined; + self.sourceNavigation.select(self.task, self.task.mapping, self.selectedRowset?.previous); } } loadNext(): void { const self = this; if (self.task && self.selectedRowset?.next) { - self.sourceNavigation.select(self.task.id, self.selectedRowset?.next); + this.error.message = undefined; + self.sourceNavigation.select(self.task, self.task.mapping, self.selectedRowset?.next); } } @@ -439,14 +540,23 @@ export class MappingDetailComponent implements OnInit, OnDestroy { } context.pageIndex = pageIndex; - self.sourceNavigation.loadSourceNav(self.task.id, context, firstIndex); + self.sourceNavigation.loadSourceNav(self.task, self.task.mapping, context, firstIndex); + } + } + + updateStatus2(status: string): void { + const mrStatus: MapRowStatus | null = toMapRowStatus(status); + if (mrStatus) { + this.updateStatus(mrStatus); } + } updateStatus($event: MapRowStatus): void { const self = this; if (self.rowId && $event) { - self.mapService.updateStatus(self.rowId, $event).subscribe((result) => { + if (this.canSaveTarget($event)) { + self.mapService.updateStatus(self.rowId, $event).subscribe((result) => { self.source.status = result.status; if (self.task && self.selectedRowset?.current && self.selectedRowset?.mapRow) { self.selectedRowset.mapRow.updateFromRow(result as MapRow); @@ -454,9 +564,10 @@ export class MappingDetailComponent implements OnInit, OnDestroy { }, (error) => this.translate.get('ERROR.TARGET_NOT_UPDATED').subscribe((msg) => { this.error.message = msg; - }) - ); + })) + } } + } updateFlag($event: MapView): void { @@ -466,6 +577,13 @@ export class MappingDetailComponent implements OnInit, OnDestroy { } } + updateTags($event: MapView): void { + const self = this; + if ($event && $event.targetId && $event.tags !== undefined) { + self.mapService.updateTags($event.targetId, $event.tags).subscribe(); + } + } + getStatusList(): MapRowStatus[] { const self = this; return self.task && self.source.status ? diff --git a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.css b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.css index 9310d13d..124db3f3 100644 --- a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.css +++ b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.css @@ -9,6 +9,17 @@ overflow: hidden; } +.col-middle { + flex: 2; /* make col-middle content appear in the middle */ + /* align-content: end; display: grid; aligns to bottom of column */ + align-content: end; + display: grid; +} + +.out-of-scope-text { + padding: 12px; +} + .col-action { max-width: 20%; flex: 1; @@ -39,3 +50,7 @@ a.mat-primary { bottom: 0.5em; right: 0.5em; } + +.dual-map-text { + font-size: 14px; +} \ No newline at end of file diff --git a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.html b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.html index b5d56ab7..96b8ce36 100644 --- a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.html +++ b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.html @@ -2,7 +2,7 @@
-

{{mapping.project.title}}

+

{{mapping.project.title}} - ({{(mapping.project.dualMapMode ? 'MAP.DUAL_MAP' : 'MAP.SINGLE_MAP') | translate}})

  • {{'SOURCE.SOURCE' | translate}}: {{mapping.source.name}}, {{'MAP.VERSION' | translate}} {{mapping.source.version}} @@ -15,6 +15,9 @@

    {{mapping.project.title}}

+
+ {{'MAP.NUM_TARGETS_OUT_OF_SCOPE' | translate}}: {{outOfScopeTargetCount}} +
{{'MAP.MAP_VERSION' | translate}} diff --git a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.spec.ts b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.spec.ts index e75beb19..0842ec26 100644 --- a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.spec.ts +++ b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.spec.ts @@ -20,6 +20,7 @@ import {By} from '@angular/platform-browser'; import {RouterTestingModule} from '@angular/router/testing'; import { provideMockStore } from '@ngrx/store/testing'; import {TranslateLoader, TranslateModule} from '@ngx-translate/core'; +import { APP_CONFIG } from 'src/app/app.config'; import {HttpLoaderFactory} from 'src/app/app.module'; import { selectAuthorizedProjects } from 'src/app/store/app.selectors'; import { initialAppState } from 'src/app/store/app.state'; @@ -51,6 +52,7 @@ describe('MappingDetailsCardComponent', () => { }) ], providers: [ + {provide: APP_CONFIG, useValue: {appName: 'Snap2SNOMED', authDomainUrl: 'anything'}}, provideMockStore({ initialState: initialAppState, selectors: [ @@ -77,6 +79,7 @@ describe('MappingDetailsCardComponent', () => { project = mapping.project; project.id = '1'; project.title = 'Test Map'; + project.dualMapMode = false; mapping.project.maps.push(mapping); @@ -99,7 +102,7 @@ describe('MappingDetailsCardComponent', () => { it('should show Map title', () => { fixture.detectChanges(); const el = fixture.debugElement.query(By.css('h2')); - expect(el.nativeElement.textContent).toBe('Test Map'); + expect(el.nativeElement.textContent).toBe('Test Map - (MAP.SINGLE_MAP)'); expect(el).toBeTruthy(); }); diff --git a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.ts b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.ts index 7bdc8e77..d3db8eff 100644 --- a/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-details-card/mapping-details-card.component.ts @@ -19,6 +19,9 @@ import { MatSelectChange } from '@angular/material/select'; import { Router } from '@angular/router'; import {TranslateService} from '@ngx-translate/core'; import {Mapping} from 'src/app/_models/mapping'; +import { TARGET_OUT_OF_SCOPE_TAG } from 'src/app/_models/map_row'; +import { MapService } from 'src/app/_services/map.service'; +import { TargetChangedService } from 'src/app/_services/target-changed.service'; @Component({ selector: 'app-mapping-details-card', @@ -32,8 +35,30 @@ export class MappingDetailsCardComponent { @Output() clicked = new EventEmitter(); + outOfScopeTargetCount: number | string = ""; + constructor(private translate: TranslateService, - private router: Router) { + private router: Router, + private mapService: MapService, + private targetChangedService: TargetChangedService) { + } + + ngOnInit() { + + this.targetChangedService.targetChanged$.subscribe(row => { + this.updateNumOutOfScopeTargets(); + }) + + this.updateNumOutOfScopeTargets(); + + } + + updateNumOutOfScopeTargets() { + if (this.mapping.id) { + this.mapService.getTagCount(this.mapping.id, TARGET_OUT_OF_SCOPE_TAG).subscribe((result) => { + this.outOfScopeTargetCount = result.page.totalElements; + }); + } } ngOnChanges(changes: SimpleChanges): void { diff --git a/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.html b/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.html index 95862a41..21dc138d 100644 --- a/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.html +++ b/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.html @@ -1,5 +1,5 @@ - + diff --git a/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.ts b/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.ts index 3e76184f..44551bf0 100644 --- a/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-list/mapping-list.component.ts @@ -65,7 +65,7 @@ export class MappingListComponent implements OnInit, AfterViewInit, OnDestroy { componentLoaded = false; selectedMapping: { [key: string]: Mapping | null } = {}; - newMapping!: Mapping; + newMapping: Mapping | undefined; mode = 'FORM.CREATE'; opened = false; @@ -135,11 +135,11 @@ export class MappingListComponent implements OnInit, AfterViewInit, OnDestroy { if (window.history.state?.error) { this.setError(window.history.state.error); } - } - ngAfterViewInit(): void { this.getProjects(); + } + ngAfterViewInit(): void { if (this.sort) { this.sort.sortChange.pipe(tap(() => { if (this.sort?.direction) { @@ -209,6 +209,7 @@ export class MappingListComponent implements OnInit, AfterViewInit, OnDestroy { createMap(): void { this.mode = 'FORM.CREATE'; + this.newMapping = undefined; // just in case "new version" is clicked before "create map" this.opened = true; } diff --git a/ui/snapclient/src/app/mapping/mapping-table-notes/mapping-notes.component.ts b/ui/snapclient/src/app/mapping/mapping-table-notes/mapping-notes.component.ts index 04a24210..c234ea92 100644 --- a/ui/snapclient/src/app/mapping/mapping-table-notes/mapping-notes.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-table-notes/mapping-notes.component.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2022 SNOMED International + * Copyright © 2022-23 SNOMED International * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,7 @@ import { Component, Inject, Input, OnDestroy, OnInit, ViewChild} from '@angular/core'; import { Subscription } from 'rxjs'; -import { Note } from 'src/app/_models/note'; +import { Note, NoteCategory } from 'src/app/_models/note'; import { MapService, NoteResults } from 'src/app/_services/map.service'; import { MatTable, MatTableDataSource } from '@angular/material/table'; @@ -60,7 +60,7 @@ export class MappingNotesComponent implements OnInit, OnDestroy { const self = this; if (this.data.rowId) { - this.mapService.getNotesByMapRow(this.data.rowId).subscribe((results: NoteResults) => { + this.mapService.getNotesByMapRow(this.data.rowId, NoteCategory.USER).subscribe((results: NoteResults) => { self.notes = results._embedded.notes.map((note) => { note.noteBy.givenName = note.noteBy.givenName ?? ''; note.noteBy.familyName = note.noteBy.familyName ?? ''; diff --git a/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.html b/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.html index 9b30b4ff..3b58cce5 100644 --- a/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.html +++ b/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.html @@ -11,15 +11,17 @@ - + [checked]="isAllSelected || checkSelected(row)" + [disabled]="isAllSelected"> diff --git a/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.ts b/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.ts index e2450441..456f4e7f 100644 --- a/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-table-selector/mapping-table-selector.component.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AfterViewInit, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Optional, Output, ViewChild } from '@angular/core'; import { MatColumnDef, MatTable } from '@angular/material/table'; import { Store } from '@ngrx/store'; import { Subscription } from 'rxjs'; @@ -47,6 +47,7 @@ export class MappingTableSelectorComponent implements OnInit, OnDestroy, AfterVi this.columnDef.name = name; } } + @Output() allSelectedEvent = new EventEmitter(); selectedRows: MappedRowDetailsDto[] = []; isAllSelected = false; @@ -80,14 +81,15 @@ export class MappingTableSelectorComponent implements OnInit, OnDestroy, AfterVi } }) ); + + // make sure ngOnDestroy is called if page is reloaded, otherwise the selection will behave strangely for the user + // e.g. a select all turns into every thing selected on the page due to selected rows containing the rows in the current + // page + window.onbeforeunload = () => this.ngOnDestroy(); } ngAfterViewInit(): void { this.isAnySelected = false; - if (this.selectedRows && this.selectedRows.length > 0 && this.selectedRows.length === this.allSourceDetails.length) { - this.isAllSelected = true; - this.isAnySelected = true; - } let foundOnPage = 0; this.page.data.forEach(row => { if (this.isSelectedPageRow(row.rowId, row.targetId, row.sourceIndex)) { @@ -167,19 +169,27 @@ export class MappingTableSelectorComponent implements OnInit, OnDestroy, AfterVi this.selectedRows = Object.assign([], this.allSourceDetails); this.isAllSelected = true; this.isAnySelected = true; + this.isPageSelected = false; this.lastSelected = 0; this.store.dispatch(new SelectMapRow({selectedrows: this.selectedRows})); + this.allSelectedEvent.emit(true); } else { this.clearAllSelectedRows(); this.isAllSelected = false; this.isAnySelected = false; + this.isPageSelected = false; this.lastSelected = 0; this.store.dispatch(new SelectMapRow({selectedrows: []})); + this.allSelectedEvent.emit(false); } this.ngAfterViewInit(); } selectPageToggle(event: any): void { + if (this.isAllSelected) { + this.isAllSelected = false; + this.allSelectedEvent.emit(false); + } if (event.checked) { this.page.data.forEach(row => { const newSelectedRow = new MappedRowDetailsDto( @@ -209,9 +219,9 @@ export class MappingTableSelectorComponent implements OnInit, OnDestroy, AfterVi } checkSelected(row: MapView): boolean { - return this.selectedRows.filter(selectedRow => - selectedRow.sourceIndex === parseInt(row.sourceIndex) - && selectedRow.mapRowTargetId === row.targetId).length > 0; + return this.selectedRows.filter(selectedRow => { + return selectedRow.sourceIndex === parseInt(row.sourceIndex) + && (row.targetId ? selectedRow.mapRowTargetId === parseInt(row.targetId) : true)}).length > 0; } toggleSelection(event: any, row: MapView, index: number): void { @@ -252,6 +262,7 @@ export class MappingTableSelectorComponent implements OnInit, OnDestroy, AfterVi clearAllSelectedRows(): void { this.selectedRows = []; this.isAllSelected = false; + this.allSelectedEvent.emit(false); this.isPageSelected = false; this.store.dispatch(new SelectMapRow({selectedrows: []})); } @@ -268,7 +279,11 @@ export class MappingTableSelectorComponent implements OnInit, OnDestroy, AfterVi if (this.selectedRows) { if (this.selectedRows.length > 9999) { return '9999+'; - } else { + } + else if (this.isAllSelected) { + return this.page.totalElements.toString(); + } + else { return String(this.selectedRows.length); } } diff --git a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.css b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.css index a544bbd1..20ff35b5 100644 --- a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.css +++ b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.css @@ -44,6 +44,12 @@ text-align: center; } +.mat-column-filter-lastAuthorReviewer, +.mat-column-lastAuthorReviewer { + width: 60px; + text-align: left; +} + .mat-column-filter-sourceCode, .mat-column-filter-targetCode, .mat-column-bulk-source-code, .mat-column-bulk-target-code, .mat-column-filter-sourceCode .mat-form-field, .mat-column-filter-targetCode .mat-form-field, @@ -67,7 +73,13 @@ width: 95% !important; } -.mat-column-noMap, mat-column-bulk-noMap, .mat-column-filter-noMap, .mat-column-flagged, .mat-column-filter-flagged, .mat-column-notes, .mat-column-filter-notes { +.mat-column-targetOutOfScope, +.mat-column-filter-targetOutOfScope, +.mat-column-noMap, +mat-column-bulk-noMap, +.mat-column-filter-noMap, +.mat-column-flagged, +.mat-column-filter-flagged { width: 60px; } @@ -75,6 +87,10 @@ width: 60px; } +::ng-deep .mat-column-filter-targetOutOfScope .mat-form-field{ + width: 60px; +} + ::ng-deep .mat-column-filter-flagged .mat-form-field{ width: 60px; } @@ -83,6 +99,10 @@ width: 60px; } +::ng-deep .mat-column-filter-lastAuthorReviewer .mat-form-field { + width: 60px; +} + .mat-column-relationship, .mat-column-bulk-relationship, .mat-column-status { width: 120px; font-size: 0.8em; @@ -90,7 +110,7 @@ .mat-column-latestNote, .mat-column-actions { width: 60px; - text-align: right; + text-align: left; } ::ng-deep .mat-column-filter-relationship .mat-form-field, diff --git a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.html b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.html index 4e8545f8..300e3613 100644 --- a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.html +++ b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.html @@ -11,7 +11,8 @@ + [page]="page" + (allSelectedEvent)="onAllSelected($event)"> @@ -91,6 +92,16 @@ >chat_bubble_outline + + + {{'TABLE.TARGET_OUT_OF_SCOPE' | translate}} + + + + {{'TABLE.FLAG' | translate}} @@ -107,6 +118,14 @@ + + + {{'TABLE.LAST_AUTHOR' | translate}} + + + + {{'TABLE.ACTIONS' | translate}} @@ -218,6 +237,19 @@ + + + + + + --- + {{'NOMAP.' + opt[0] | translate}} + + + + @@ -244,6 +276,22 @@ + + + + + + {{'TASK.UNASSIGNED' | translate}} + + + + + diff --git a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.spec.ts b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.spec.ts index 9a2a4c14..affc92a7 100644 --- a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.spec.ts +++ b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.spec.ts @@ -134,13 +134,13 @@ describe('MappingTableComponent', () => { component.allSourceDetails = []; component.filterEnabled = false; const mapViews: MapView[] = []; - const tobeSpiedOn = new MapView('1', '1231', '1', '1111', 'test source', + const tobeSpiedOn = new MapView('1', '1231', '1', '1', '1111', 'test source', '1234', 'testtarget', 'EQUIVALENT', 'DRAFT', false, null, - null, null, null, null, false, []); + null, null, null, null, null, false, false, undefined, [], null); mapViews.push(tobeSpiedOn); - mapViews.push(new MapView('2', '0000', '2', '2222', 'test source2', + mapViews.push(new MapView('2', '0000', '2', '2', '2222', 'test source2', '5678', 'testtarget2', 'EQUIVALENT', 'DRAFT', false, null, - null, null, null, null, false, [])); + null, null, null, null, null, false, false, undefined, [], null)); const page = new Page(mapViews, 0, 20, 2, 1); mockMapService.getMapView.and.returnValue( of({ diff --git a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.ts b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.ts index 4d4d2af5..3b017d10 100644 --- a/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-table/mapping-table.component.ts @@ -25,12 +25,12 @@ import { mapRowStatuses, MapView, MapViewFilter, - Page + Page, + TARGET_OUT_OF_SCOPE_TAG } from '../../_models/map_row'; import {MatPaginator, PageEvent} from '@angular/material/paginator'; import {MatSort, Sort, SortDirection} from '@angular/material/sort'; import {Subscription} from 'rxjs'; -import {debounceTime, distinctUntilChanged} from 'rxjs/operators'; import {Store} from '@ngrx/store'; import {IAppState} from '../../store/app.state'; import {Task, TaskType} from '../../_models/task'; @@ -51,6 +51,7 @@ import {MatTable} from '@angular/material/table'; import {WriteDisableUtils} from '../../_utils/write_disable_utils'; import {FhirService} from "../../_services/fhir.service"; import { MappingNotesComponent } from '../mapping-table-notes/mapping-notes.component'; +import { TargetChangedService } from 'src/app/_services/target-changed.service'; export interface TableParams extends Params { pageIndex?: number; @@ -89,8 +90,10 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { {columnId: 'relationship', columnDisplay: 'TABLE.RELATIONSHIP', displayed: true}, {columnId: 'noMap', columnDisplay: 'TABLE.NO_MAP', displayed: true}, {columnId: 'status', columnDisplay: 'TABLE.STATUS', displayed: true}, + {columnId: 'targetOutOfScope', columnDisplay: 'TABLE.TARGET_OUT_OF_SCOPE', displayed: true}, {columnId: 'flagged', columnDisplay: 'TABLE.FLAG', displayed: true}, {columnId: 'latestNote', columnDisplay: 'SOURCE.TABLE.NOTES', displayed: true}, + {columnId: 'lastAuthorReviewer', columnDisplay: 'TABLE.LAST_AUTHOR_REVIEWER', displayed: true}, {columnId: 'actions', columnDisplay: '', displayed: true} ]; additionalDisplayedColumns: TableColumn[] = []; @@ -106,8 +109,10 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { 'filter-relationship', 'filter-noMap', 'filter-status', + 'filter-targetOutOfScope', 'filter-flagged', 'filter-notes', + 'filter-lastAuthorReviewer', 'filter-actions', ]; additionalFilteredColumns: string[] = []; @@ -119,6 +124,7 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { @Output() showNotes = new EventEmitter(); @Output() updateTableEvent = new EventEmitter(); @Output() targetConceptSearchString = new EventEmitter(); + @Output() allSelectedEvent = new EventEmitter(); mappingTableSelector: MappingTableSelectorComponent | null | undefined; @@ -139,6 +145,8 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { page: Page = new Page(); allSourceDetails: MappedRowDetailsDto[] = []; writeDisableUtils = WriteDisableUtils; + + allSelected = false; private subscription = new Subscription(); private debounce = 200; @@ -172,7 +180,8 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { public translate: TranslateService, private selectionService: SelectionService, private mapService: MapService, - public dialog: MatDialog) { + public dialog: MatDialog, + private targetChangedService: TargetChangedService) { const initialSelection: MapView[] = []; const allowMultiSelect = true; const emitChanges = true; @@ -199,9 +208,11 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { this.additionalDisplayedColumns = []; this.additionalFilteredColumns = []; - for (let i = 0; i < this.page.additionalColumns.length; i++) { - this.additionalDisplayedColumns.push({columnId: "additionalColumn" + (i+1), columnDisplay: this.page.additionalColumns[i].name, displayed: true}); - this.additionalFilteredColumns.push("filter-additionalColumn" + (i+1)); + if (this.page.additionalColumns) { + for (let i = 0; i < this.page.additionalColumns.length; i++) { + this.additionalDisplayedColumns.push({columnId: "additionalColumn" + (i+1), columnDisplay: this.page.additionalColumns[i].name, displayed: true}); + this.additionalFilteredColumns.push("filter-additionalColumn" + (i+1)); + } } // display additional columns at the end of the table @@ -301,14 +312,14 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { const self = this; const mapView: MapView = self.page?.data[index] as MapView; if (mapView) { - if ($event.checked && mapView.targetCode && mapView.targetCode !== '') { + if (($event.checked && mapView.targetCode && mapView.targetCode !== '') || ($event.checked && self.task!.type == TaskType.RECONCILE)) { // when noMap ON - remove any targets - confirm const confirmDialogRef = self.dialog.open(ConfirmDialogComponent, {data: self.getNomapConfirmDialogData()}); confirmDialogRef.afterClosed().subscribe( (ok) => { if (ok) { - self.mapService.updateNoMap(mapView.rowId, mapView.noMap).subscribe((res) => { + self.mapService.updateNoMap(mapView.rowId, mapView.noMap, this.task!.type === TaskType.RECONCILE).subscribe((res) => { mapView.updateFromRow(res); self.updateTableEvent.emit(); }); @@ -326,7 +337,7 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { }); } else { - self.mapService.updateNoMap(mapView.rowId, mapView.noMap).subscribe((res) => { + self.mapService.updateNoMap(mapView.rowId, mapView.noMap, this.task!.type === TaskType.RECONCILE).subscribe((res) => { mapView.updateFromRow(res); self.updateTableEvent.emit(); }); @@ -368,7 +379,7 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { const self = this; const mapView: MapView = $event ? $event as MapView : self.page?.data[index] as MapView; if (mapView) { - if (mapView.hasTargetOrRelationshipChanged()) { + if (mapView.hasTargetOrRelationshipChanged() && !(self.task!.type == TaskType.RECONCILE)) { mapView.status = MapRowStatus.DRAFT; } this.doMapRowTargetUodate(self, mapView); @@ -384,13 +395,15 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { } private doMapRowTargetUodate(self: this, mapView: MapView): void { - self.mapService.updateMapRowTarget(mapView).subscribe( + const [result, targetRow] = self.mapService.updateMapRowTarget(mapView, self.task!.type); + result.subscribe( (result) => { mapView.updateFromTarget(result); self.mapService.updateStatus(mapView.rowId, mapView.status as MapRowStatus).subscribe((saved) => { mapView.updateStatus(saved.status); self.updateTableEvent.emit(); }); + this.targetChangedService.changeTarget(targetRow); }, (error) => { mapView.reset(); @@ -402,10 +415,11 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { const self = this; const row = self.page?.data[index]; if (row && self.task.mapping.id) { - self.mapService.findTargetsBySourceIndex(self.task.mapping.id, row.sourceIndex) + self.mapService.findTargetsBySourceIndex(self.task.mapping.id, row.sourceIndex, undefined) .subscribe(rows => { - const targetCodes = rows._embedded.mapRowTargets.map(target => target.targetCode); - if (targetCodes.includes(event.data?.code)) { + const matchingTargetCodes = rows._embedded.mapRowTargets.filter(target => target.targetCode==event.data?.code); + if ((this.task?.mapping.project.dualMapMode && matchingTargetCodes.length > 1) || + (!this.task?.mapping.project.dualMapMode && matchingTargetCodes.length > 0)) { self.dialog.open(ConfirmDialogComponent, {data: self.getDuplicateTargetDialogData()}); } else { self.fhirService.getEnglishFsn(event.data?.code, event.data?.system, self.task?.mapping?.toVersion || '').subscribe(englishFsn => { @@ -416,8 +430,17 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { row.targetCode = event.data?.code; row.targetDisplay = displayTerm; - row.status = MapRowStatus.DRAFT; + if (this.task!.type !== TaskType.RECONCILE) { + row.status = MapRowStatus.DRAFT; + } row.relationship = MapRowRelationship.INEXACT; + row.targetOutOfScope = false; + + const tagIndex = row.tags?.indexOf(TARGET_OUT_OF_SCOPE_TAG, 0); + if (tagIndex !== undefined && tagIndex > -1) { + row.tags?.splice(tagIndex, 1); + } + self.updateMapRowTarget(row, index); }); } @@ -507,4 +530,9 @@ export class MappingTableComponent implements OnInit, AfterViewInit, OnDestroy { return this.task ? StatusUtils.getAvailableStatusOptions(this.task.type as TaskType, mapRow.status as MapRowStatus) : mapRowStatuses; } + onAllSelected(allSelected: boolean) { + this.allSelected = allSelected + this.allSelectedEvent.emit(allSelected); + } + } diff --git a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.css b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.css index 108d2126..f44e4bf3 100644 --- a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.css +++ b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.css @@ -91,8 +91,16 @@ ul.sources-list li { } +.mat-column-targetOutOfScope, +.mat-column-filter-targetOutOfScope { + width: 60px; + text-align: center; +} + .mat-column-filter-assignedAuthor, .mat-column-assignedAuthor, +.mat-column-filter-assignedReconciler, +.mat-column-assignedReconciler, .mat-column-filter-assignedReviewer, .mat-column-assignedReviewer, .mat-column-filter-lastAuthorReviewer, @@ -137,6 +145,12 @@ ul.sources-list li { width: 100px; } + +::ng-deep .mat-column-filter-targetOutOfScope .mat-form-field{ + width: 60px; +} + + ::ng-deep .mat-column-filter-flagged .mat-form-field{ width: 60px; } @@ -150,6 +164,11 @@ ul.sources-list li { text-align: center; } +::ng-deep .mat-column-filter-assignedReconciler .mat-form-field { + width: 60px; + text-align: center; +} + ::ng-deep .mat-column-filter-assignedReviewer .mat-form-field { width: 60px; text-align: center; diff --git a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.html b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.html index 3837e576..016681ab 100644 --- a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.html +++ b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.html @@ -4,7 +4,7 @@
- +
@@ -24,9 +24,12 @@ {{'MAP.EXPORT_CSV' | translate}} {{'MAP.EXPORT_TSV' | translate}} {{'MAP.EXPORT_XLSX' | translate}} + {{'MAP.EXPORT_FHIR_JSON' | translate}} + {{'MAP.EXPORT_XLSX_EXTENDED' | translate}} -   @@ -76,7 +79,8 @@ + [page]="page" + (allSelectedEvent)="onAllSelected($event)"> @@ -122,6 +126,15 @@ {{'TABLE.STATUS' | translate}} {{maprow.status}} + + + {{'TABLE.TARGET_OUT_OF_SCOPE' | translate}} + + + warning + + + {{'TABLE.FLAG' | translate}} @@ -153,7 +166,15 @@ {{'TABLE.AUTHOR' | translate}} - + + + + + {{'TABLE.RECONCILER' | translate}} + + @@ -288,6 +309,19 @@ + + + + + + --- + {{'NOMAP.' + opt[0] | translate}} + + + + @@ -330,6 +364,20 @@ + + + + + + {{'TASK.UNASSIGNED' | translate}} + + + {{member.givenName}} {{member.familyName}} + + + + + diff --git a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.spec.ts b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.spec.ts index dce5cff0..63549824 100644 --- a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.spec.ts +++ b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.spec.ts @@ -185,7 +185,7 @@ describe('MappingViewComponent', () => { it('should show Map title', () => { fixture.detectChanges(); el = fixture.debugElement.query(By.css('h2')); - expect(el.nativeElement.textContent).toBe('Test Map'); + expect(el.nativeElement.textContent).toBe('Test Map - (MAP.SINGLE_MAP)'); expect(el).toBeTruthy(); }); @@ -209,7 +209,7 @@ describe('MappingViewComponent', () => { el.triggerEventHandler('click', null); const menu = fixture.debugElement.query(By.css('.mat-menu-panel')); expect(menu).toBeTruthy(); - expect(menu.nativeElement.textContent).toBe('MAP.EXPORT_CSVMAP.EXPORT_TSVMAP.EXPORT_XLSX'); + expect(menu.nativeElement.textContent).toBe('MAP.EXPORT_CSVMAP.EXPORT_TSVMAP.EXPORT_XLSXMAP.EXPORT_FHIR_JSONMAP.EXPORT_XLSX_EXTENDED'); }); }); diff --git a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.ts b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.ts index 411e4418..9c5041c4 100644 --- a/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-view/mapping-view.component.ts @@ -67,6 +67,8 @@ import {MappingImportComponent} from '../mapping-import/mapping-import.component import { MappingNotesComponent } from '../mapping-table-notes/mapping-notes.component'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { TableColumn } from '../mapping-table/mapping-table.component'; +import { TargetChangedService } from 'src/app/_services/target-changed.service'; +import { cloneDeep } from 'lodash'; @Component({ selector: 'app-mapping-view', @@ -87,6 +89,7 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { sort: MatSort | null | undefined; componentLoaded = false; mappingTableSelector: MappingTableSelectorComponent | null | undefined; + allSelected = false; // @ts-ignore @ViewChild(MatTable) table: MatTable; @@ -125,11 +128,12 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { {columnId: 'relationship', columnDisplay: 'TABLE.RELATIONSHIP', displayed: true}, {columnId: 'noMap', columnDisplay: 'TABLE.NO_MAP', displayed: true}, {columnId: 'status', columnDisplay: 'TABLE.STATUS', displayed: true}, + {columnId: 'targetOutOfScope', columnDisplay: 'TABLE.TARGET_OUT_OF_SCOPE', displayed: true}, {columnId: 'flagged', columnDisplay: 'TABLE.FLAG', displayed: true}, {columnId: 'latestNote', columnDisplay: 'SOURCE.TABLE.NOTES', displayed: true}, {columnId: 'lastAuthorReviewer', columnDisplay: 'TABLE.LAST_AUTHOR_REVIEWER', displayed: true}, {columnId: 'assignedAuthor', columnDisplay: 'TABLE.AUTHOR', displayed: true}, - {columnId: 'assignedReviewer', columnDisplay: 'TABLE.REVIEWER', displayed: true}, + {columnId: 'assignedReviewer', columnDisplay: 'TABLE.REVIEWER', displayed: true} ]; // columns that are eligable for user controlling the hiding / displaying hideShowColumns: string[] = [ @@ -159,6 +163,7 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { 'filter-relationship', 'filter-noMap', 'filter-status', + 'filter-targetOutOfScope', 'filter-flagged', 'filter-notes', 'filter-lastAuthorReviewer', @@ -188,6 +193,8 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { authCurrentPage = 0; reviewPageSize = 10; reviewCurrentPage = 0; + reconcilePageSize = 10; + reconcileCurrentPage = 0; private timeout: NodeJS.Timeout | null = null; @@ -213,7 +220,8 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { private store: Store, private translate: TranslateService, private mapService: MapService, - private authService: AuthService,) { + private authService: AuthService, + private targetChangedService: TargetChangedService) { this.translate.get('TASK.SELECT_A_TASK').subscribe((res) => this.selectedLabel = res); this.paging = new MapViewPaging(); @@ -382,9 +390,17 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { (mapping) => { if (this.mapping_id === mapping?.id) { self.mapping = mapping; + + // add in the reconciler column to the table if it is a dual map + if (this.mapping?.project.dualMapMode) { + this.addReconcilerTableColumn(); + } + if (self.mapping && self.mapping.id && self.mapping_id === self.mapping.id) { - self.store.dispatch(new LoadTasksForMap({id: self.mapping.id, authPageSize: self.authPageSize, - authCurrentPage: self.authCurrentPage, reviewPageSize: self.reviewPageSize, reviewCurrentPage: self.reviewCurrentPage})); + self.store.dispatch(new LoadTasksForMap({id: self.mapping.id, + authPageSize: self.authPageSize, authCurrentPage: self.authCurrentPage, + reviewPageSize: self.reviewPageSize, reviewCurrentPage: self.reviewCurrentPage, + reconcilePageSize: self.reconcilePageSize, reconcileCurrentPage: self.reconcileCurrentPage})); self.members = self.mapping.project.owners.concat(self.mapping.project.members).concat(self.mapping.project.guests); } } @@ -497,7 +513,7 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { this.opened = false; } - exportMapView(type: string): void { + exportMapViewAdditionalColumns(type: string, additionalColumns: string[]) { this.setLoading(); let contentType: string; let extension: string; @@ -513,6 +529,11 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { extension = '.xlsx'; break; + case 'fhir-json': + contentType = 'application/fhir+json'; + extension = '.json'; + break; + case 'csv': default: contentType = 'text/csv'; @@ -520,7 +541,7 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { } if (this.mapping && this.mapping.id) { - this.mapService.exportMapView(this.mapping.id, contentType) + this.mapService.exportMapView(this.mapping.id, contentType, additionalColumns) .subscribe(blob => saveAs(blob, this.mapping?.project.title + '_' + this.mapping?.mapVersion + extension), (error) => { console.log(error); @@ -529,6 +550,10 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { } } + exportMapView(type: string): void { + this.exportMapViewAdditionalColumns(type, []); + } + loadTaskList(): void { const self = this; this.subscription.add(self.store.select(selectTaskList).pipe(debounceTime(200)).subscribe( @@ -544,6 +569,17 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { )); } + addReconcilerTableColumn() { + + // check if already added as sometimes the column appears multiple times due to multiple mapping selection events + if (this.constantFilteredColumns[this.constantFilteredColumns.length-1] !== "filter-assignedReconciler") { + this.constantFilteredColumns.push("filter-assignedReconciler"); + this.constantHideShowColumns.push("assignedReconciler"); + this.constantColumns.push({columnId: 'assignedReconciler', columnDisplay: 'TABLE.RECONCILER', displayed: true}); + } + + } + explainRelationship(relationship: string | null): string { return ServiceUtils.explainRelationship(this.translate, relationship); } @@ -610,6 +646,15 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { return this.mapping?.project.owners.map(u => u.id).includes(this.currentUser.id) ?? false; } + isDualMapMode(): boolean { + let isDualMapMode = false; + + if (this.mapping && this.mapping.project.dualMapMode) { + isDualMapMode = this.mapping.project.dualMapMode; + } + return isDualMapMode; + } + private setLoading(): void { // in case action is taking some time, display spinner this.timeout = setTimeout(() => { @@ -646,6 +691,7 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { if ((validationResult.inactive.length + validationResult.absent.length + validationResult.invalid.length) > 0) { this.refreshTable('validated'); } + this.targetChangedService.changeTarget({}); // needs to update on a zero count this.clearLoading(); }, err => { this.validateError(err); @@ -706,12 +752,17 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { }); } + onAllSelected(allSelected: boolean) { + this.allSelected = allSelected; + } + getBulkChangeDialogData(): BulkChangeDialogData { return { task: null, map: this.mapping, isMapView: this.isOwner(), - selectedRows: this.mappingTableSelector?.selectedRows + selectedRows: this.mappingTableSelector?.selectedRows, + allSelected: this.allSelected }; } @@ -730,6 +781,9 @@ export class MappingViewComponent implements OnInit, AfterViewInit, OnDestroy { case TaskType.REVIEW: this.reviewCurrentPage = 0; break; + case TaskType.RECONCILE: + this.reconcileCurrentPage = 0; + break; } } } diff --git a/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.html b/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.html index 9ee91c58..5263f7d9 100644 --- a/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.html +++ b/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.html @@ -69,7 +69,6 @@

{{task.type | translate}}

- {{'MAP.TARGET_PROPERTIES' | translate}}
@@ -101,6 +100,7 @@

{{task.type | translate}}

(pagingChange)="pagingChange($event)" (updateTableEvent)="updateTable($event)" (targetConceptSearchString)="targetConceptSearchString($event)" + (allSelectedEvent)="allSelectedChange($event)" [displayedColumns]="displayedColumns" >
diff --git a/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.spec.ts b/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.spec.ts index 51216f1f..bb7a0206 100644 --- a/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.spec.ts +++ b/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.spec.ts @@ -114,6 +114,6 @@ describe('MappingWorkComponent', () => { component.task = task; fixture.detectChanges(); el = fixture.debugElement.query(By.css('h2#map-title')); - expect(el.nativeElement.textContent).toBe(task.mapping.project.title); + expect(el.nativeElement.textContent).toBe(task.mapping.project.title + ' - (MAP.SINGLE_MAP)'); }); }); diff --git a/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.ts b/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.ts index 9324b202..52019a0a 100644 --- a/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.ts +++ b/ui/snapclient/src/app/mapping/mapping-work/mapping-work.component.ts @@ -60,6 +60,8 @@ const enum TaskMode { AUTHOR_DETAILS = 'AUTHOR_DETAILS_VIEW', REVIEW_TABLE = 'REVIEW_TABLE_VIEW', REVIEW_DETAILS = 'REVIEW_DETAILS_VIEW', + RECONCILE_TABLE = 'RECONCILE_TABLE_VIEW', + RECONCILE_DETAILS = 'RECONCILE_DETAILS_VIEW', } @Component({ @@ -99,7 +101,8 @@ export class MappingWorkComponent implements OnInit, OnDestroy { @ViewChild('mapTable', {static: false}) mapTable: MappingTableComponent | undefined; automapping = false; isAdmin = false; - private navigationSubscription: Subscription; + // private navigationSubscription: Subscription; + allSelected = false; targetConceptSearchText = ''; @@ -116,6 +119,7 @@ export class MappingWorkComponent implements OnInit, OnDestroy { {columnId: 'relationship', columnDisplay: 'TABLE.RELATIONSHIP', displayed: true}, {columnId: 'noMap', columnDisplay: 'TABLE.NO_MAP', displayed: true}, {columnId: 'status', columnDisplay: 'TABLE.STATUS', displayed: true}, + {columnId: 'targetOutOfScope', columnDisplay: 'TABLE.TARGET_OUT_OF_SCOPE', displayed: true}, {columnId: 'flagged', columnDisplay: 'TABLE.FLAG', displayed: true}, {columnId: 'latestNote', columnDisplay: 'SOURCE.TABLE.NOTES', displayed: true}, {columnId: 'actions', columnDisplay: '', displayed: true} @@ -151,18 +155,20 @@ export class MappingWorkComponent implements OnInit, OnDestroy { this.tableParams = {}; this.tableFilter = new MapViewFilter(); this.isAdmin = this.authService.isAdmin(); - this.navigationSubscription = this.router.events.subscribe((e: any) => { - // Refresh page re-load params and data - if (e instanceof NavigationEnd) { - this.handleParams(); - } - }); +// this.navigationSubscription = this.router.events.subscribe((e: any) => { +// // Refresh page re-load params and data +// if (e instanceof NavigationEnd) { +// console.log('NAV END -> Handle params') +// this.handleParams(); +// } +// }); } ngOnInit(): void { const self = this; self.loadHeirarchy(); + console.log('INIT -> Handle params') self.handleParams(); self.automapping = false; @@ -193,6 +199,11 @@ export class MappingWorkComponent implements OnInit, OnDestroy { if (res) { self.error = {}; self.mapping = res; + + // add in the author column to the table if it is a dual map + if (this.mapping?.project.dualMapMode && this.task?.type === 'RECONCILE') { + this.addAuthorTableColumn(); + } } })); @@ -235,6 +246,17 @@ export class MappingWorkComponent implements OnInit, OnDestroy { })); } + private addAuthorTableColumn() { + + //check if already added as sometimes the column appears multiple times due to multiple mapping selection events + if (this.constantColumns.filter(column => column.columnId === 'lastAuthorReviewer').length < 1) { + this.constantHideShowColumns.push("lastAuthorReviewer"); + // actions should be the final column + this.constantColumns.splice(-1, 0, {columnId: 'lastAuthorReviewer', columnDisplay: 'TABLE.LAST_AUTHOR', displayed: true}); + } + + } + private loadHeirarchy(): void { const view = localStorage.getItem('hierarchyView'); if (null === view) { @@ -286,9 +308,9 @@ export class MappingWorkComponent implements OnInit, OnDestroy { this.loading = false; this.automapping = false; this.subscription.unsubscribe(); - if (this.navigationSubscription) { - this.navigationSubscription.unsubscribe(); - } + // if (this.navigationSubscription) { + // this.navigationSubscription.unsubscribe(); + // } } onSelected(node: Coding): void { @@ -330,35 +352,63 @@ export class MappingWorkComponent implements OnInit, OnDestroy { if (null !== self.task) { self.automapping = false; if (self.source) { - self.mode = self.task.type === TaskType.AUTHOR ? TaskMode.AUTHOR_DETAILS : TaskMode.REVIEW_DETAILS; + switch(self.task.type) { + case TaskType.AUTHOR: { + self.mode = TaskMode.AUTHOR_DETAILS; + break; + } + case TaskType.REVIEW: { + self.mode = TaskMode.REVIEW_DETAILS; + break; + } + case TaskType.RECONCILE: { + self.mode = TaskMode.RECONCILE_DETAILS; + break; + } + } } else { - self.mode = self.task.type === TaskType.AUTHOR ? TaskMode.AUTHOR_TABLE : TaskMode.REVIEW_TABLE; + switch(self.task.type) { + case TaskType.AUTHOR: { + self.mode = TaskMode.AUTHOR_TABLE; + break; + } + case TaskType.REVIEW: { + self.mode = TaskMode.REVIEW_TABLE; + break; + } + case TaskType.RECONCILE: { + self.mode = TaskMode.RECONCILE_TABLE; + break; + } + } } } } } handleParams(): void { - this.route.params.subscribe(params => { + this.subscription.add(this.route.params.subscribe(params => { const mappingid = ServiceUtils.cleanParamId(params.mappingid); const taskid = ServiceUtils.cleanParamId(params.taskid); - if (mappingid) { + if (mappingid && this.mapping_id !== mappingid) { this.mapping_id = mappingid; this.store.dispatch(new LoadMapping({id: mappingid})); } - if (this.task_id !== taskid && taskid) { + if (taskid && this.task_id !== taskid) { this.task_id = taskid; this.updateCurrentTask(); } - this.route.queryParams.subscribe(qparams => { - this.tableFilter = ServiceUtils.paramsToFilterEntity(qparams); - this.filterEnabled = this.tableFilter.hasFilters(); - this.tableParams = ServiceUtils.pagingParamsToTableParams(qparams); - this.loadPage(); - }); - }); + this.loadPage(); + })); + + this.subscription.add(this.route.queryParams.subscribe(qparams => { + this.tableFilter = ServiceUtils.paramsToFilterEntity(qparams); + this.filterEnabled = this.tableFilter.hasFilters(); + this.tableParams = ServiceUtils.pagingParamsToTableParams(qparams); + this.loadPage(); + })); } private getContext(): ViewContext { @@ -381,11 +431,11 @@ export class MappingWorkComponent implements OnInit, OnDestroy { } isTableView(): boolean { - return this.mode === TaskMode.AUTHOR_TABLE || this.mode === TaskMode.REVIEW_TABLE; + return this.mode === TaskMode.AUTHOR_TABLE || this.mode === TaskMode.REVIEW_TABLE || this.mode === TaskMode.RECONCILE_TABLE; } isDetailsView(): boolean { - return this.mode === TaskMode.AUTHOR_DETAILS || this.mode === TaskMode.REVIEW_DETAILS; + return this.mode === TaskMode.AUTHOR_DETAILS || this.mode === TaskMode.REVIEW_DETAILS || this.mode === TaskMode.RECONCILE_DETAILS; } filterRows(): void { @@ -434,12 +484,38 @@ export class MappingWorkComponent implements OnInit, OnDestroy { showDetail(row_idx: number): void { const self = this; - if (self.task_id) { - self.sourceNavigation.loadSourceNav(self.task_id, this.getContext(), row_idx); + if (self.task_id && self.task) { + self.sourceNavigation.loadSourceNav(self.task, self.task.mapping, this.getContext(), row_idx); self.opened = true; - self.mode = self.task?.type === 'AUTHOR' ? TaskMode.AUTHOR_DETAILS : TaskMode.REVIEW_DETAILS; + switch(self.task?.type) { + case 'AUTHOR': { + self.mode = TaskMode.AUTHOR_DETAILS; + break; + } + case 'REVIEW': { + self.mode = TaskMode.REVIEW_DETAILS; + break; + } + case 'RECONCILE': { + self.mode = TaskMode.RECONCILE_DETAILS; + break; + } + } } else { - self.mode = self.task?.type === 'AUTHOR' ? TaskMode.AUTHOR_TABLE : TaskMode.REVIEW_TABLE; + switch(self.task?.type) { + case 'AUTHOR': { + self.mode = TaskMode.AUTHOR_TABLE; + break; + } + case 'REVIEW': { + self.mode = TaskMode.REVIEW_TABLE; + break; + } + case 'RECONCILE': { + self.mode = TaskMode.RECONCILE_TABLE; + break; + } + } } } @@ -447,6 +523,10 @@ export class MappingWorkComponent implements OnInit, OnDestroy { this.targetConceptSearchText = text; } + allSelectedChange(allSelected: boolean) { + this.allSelected = allSelected; + } + sortChange(event: Sort): void { this.tableParams.sortDirection = event.direction; if (event.direction === '') { @@ -524,9 +604,10 @@ export class MappingWorkComponent implements OnInit, OnDestroy { getBulkChangeDialogData(): BulkChangeDialogData { return { task: this.task, - map: null, + map: this.mapping, isMapView: false, - selectedRows: this.mapTable?.mappingTableSelector?.selectedRows + selectedRows: this.mapTable?.mappingTableSelector?.selectedRows, + allSelected: this.allSelected }; } diff --git a/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.css b/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.css index aef34a88..b923bdc8 100644 --- a/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.css +++ b/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.css @@ -75,3 +75,7 @@ .flag { color: rgba(0,0,0,0.45); } + +#targetLastAuthorUserChip { + margin-right: 0.5em; +} diff --git a/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.html b/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.html index 13ac1611..fe44a603 100644 --- a/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.html +++ b/ui/snapclient/src/app/mapping/target-relationship/target-relationship.component.html @@ -1,13 +1,13 @@
- + {{'MAP.NO_MAPPING_AVAILABLE' | translate}} + @@ -42,4 +42,20 @@
+ + + {{'DETAILS.SYSTEM_NOTES' | translate}} +   + {{systemNotes.length}} + + + +
+
+ + +
+
+
+
diff --git a/ui/snapclient/src/app/notes/notes-list/notes-list.component.spec.ts b/ui/snapclient/src/app/notes/notes-list/notes-list.component.spec.ts index c154e61e..83a8159d 100644 --- a/ui/snapclient/src/app/notes/notes-list/notes-list.component.spec.ts +++ b/ui/snapclient/src/app/notes/notes-list/notes-list.component.spec.ts @@ -36,7 +36,7 @@ import {DebugElement} from '@angular/core'; import {Mapping} from '../../_models/mapping'; import {MatInputModule} from '@angular/material/input'; import {By} from '@angular/platform-browser'; -import {Note} from '../../_models/note'; +import {Note, NoteCategory} from '../../_models/note'; import {User} from '../../_models/user'; import {SourceCode} from '../../_models/source_code'; import {Source} from '../../_models/source'; @@ -102,7 +102,7 @@ describe('NotesListComponent', () => { id: '1', noMap: false, sourceCode: new SourceCode('code', 'display', new Source(), '1', []), status: 'DRAFT' } as MapRow; - component.newNote = new Note(null, '', new User(), '', '', row); + component.newNote = new Note(null, '', new User(), '', '', row, NoteCategory.USER); fixture.detectChanges(); }); diff --git a/ui/snapclient/src/app/notes/notes-list/notes-list.component.ts b/ui/snapclient/src/app/notes/notes-list/notes-list.component.ts index 690b0b1b..78661a1c 100644 --- a/ui/snapclient/src/app/notes/notes-list/notes-list.component.ts +++ b/ui/snapclient/src/app/notes/notes-list/notes-list.component.ts @@ -17,12 +17,12 @@ import {Component, Input, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {MapRow} from '../../_models/map_row'; import {User} from '../../_models/user'; -import {Note} from '../../_models/note'; +import {Note, NoteCategory} from '../../_models/note'; import {Store} from '@ngrx/store'; import {IAppState} from '../../store/app.state'; import {TranslateService} from '@ngx-translate/core'; import {MapService, NoteResults} from '../../_services/map.service'; -import {SourceNavigationService} from '../../_services/source-navigation.service'; +import {SourceNavSet, SourceNavigationService} from '../../_services/source-navigation.service'; import {ErrorInfo} from '../../errormessage/errormessage.component'; import {Task} from '../../_models/task'; import {Subscription} from 'rxjs'; @@ -45,11 +45,13 @@ export class NotesListComponent implements OnInit, OnDestroy { private subscription: Subscription = new Subscription(); @Input() currentUser: User | null = null; @Input() task: Task | null = null; + @Input() sourceNavSet: SourceNavSet | null = null; @ViewChild('text') formControl: FormControl | undefined; mapRow: MapRow | null = null; error: ErrorInfo = {}; notes: Note[] = []; + systemNotes: Note[] = []; newNote: Note | null; MAX_NOTE = FormUtils.MAX_NOTE; VALID_STRING_PATTERN = FormUtils.VALID_STRING_PATTERN; @@ -141,16 +143,53 @@ export class NotesListComponent implements OnInit, OnDestroy { loadNotes(): void { const self = this; if (self.mapRow?.id) { - self.newNote = new Note(null, '', new User(), '', '', self.mapRow); - self.mapService.getNotesByMapRow(self.mapRow.id).subscribe((results: NoteResults) => { + self.newNote = new Note(null, '', new User(), '', '', self.mapRow, NoteCategory.USER); + + // user notes + self.mapService.getNotesByMapRow(self.mapRow.id, NoteCategory.USER).subscribe((results: NoteResults) => { self.notes = results._embedded.notes.map((note) => { note.noteBy.givenName = note.noteBy.givenName ?? ''; note.noteBy.familyName = note.noteBy.familyName ?? ''; return note; }); + if (this.sourceNavSet?.siblingRow) { + self.mapService.getNotesByMapRow(this.sourceNavSet?.siblingRow?.rowId, NoteCategory.USER).subscribe((results: NoteResults) => { + let siblingNotes = results._embedded.notes.map((note) => { + note.noteBy.givenName = note.noteBy.givenName ?? ''; + note.noteBy.familyName = note.noteBy.familyName ?? ''; + return note; + }); + self.notes = self.notes.concat(siblingNotes); + }) + } self.notes.sort((a, b) => self.sortNotes(a, b)); }); + + // system notes + self.mapService.getNotesByMapRow(self.mapRow.id, NoteCategory.STATUS).subscribe((results: NoteResults) => { + self.systemNotes = results._embedded.notes.map((note) => { + note.noteBy.givenName = ''; + note.noteBy.familyName = ''; + note.noteBy.id = ''; + return note; + }); + if (this.sourceNavSet?.siblingRow) { + self.mapService.getNotesByMapRow(this.sourceNavSet?.siblingRow?.rowId, NoteCategory.STATUS).subscribe((results: NoteResults) => { + let siblingNotes = results._embedded.notes.map((note) => { + note.noteBy.givenName = ''; + note.noteBy.familyName = ''; + note.noteBy.id = ''; + return note; + }); + self.systemNotes = self.systemNotes.concat(siblingNotes); + }) + } + self.systemNotes.sort((a, b) => self.sortNotes(a, b)); + }); + } + + } isValid(): boolean { diff --git a/ui/snapclient/src/app/source/source-import/source-import.component.html b/ui/snapclient/src/app/source/source-import/source-import.component.html index 64fb5158..4d735945 100644 --- a/ui/snapclient/src/app/source/source-import/source-import.component.html +++ b/ui/snapclient/src/app/source/source-import/source-import.component.html @@ -31,6 +31,31 @@ {{version.value?.length || 0}}/{{MAX_VERSION}} {{ 'SOURCE.VERSION_ERROR' | translate }} + + + + {{ 'FORM.FHIR_METADATA' | translate }} + + + + {{ 'SOURCE.CODESYSTEM' | translate }} + + {{codesystem.value?.length || 0}}/{{MAX_CODESYSTEM}} + + + {{ 'SOURCE.VALUESET' | translate }} + + {{valueset.value?.length || 0}}/{{MAX_VALUESET}} + +
diff --git a/ui/snapclient/src/app/source/source-import/source-import.component.ts b/ui/snapclient/src/app/source/source-import/source-import.component.ts index 44dd3be0..b8354ff9 100644 --- a/ui/snapclient/src/app/source/source-import/source-import.component.ts +++ b/ui/snapclient/src/app/source/source-import/source-import.component.ts @@ -40,6 +40,8 @@ export class SourceImportComponent implements OnInit, OnDestroy, AfterViewChecke private readonly MAXFILESIZE: number; readonly MAX_NAME = 100; readonly MAX_VERSION = 30; + readonly MAX_CODESYSTEM = 255; + readonly MAX_VALUESET = 255; fileaccept = '.csv, .tsv, .txt'; sourceType = ''; contents = ''; diff --git a/ui/snapclient/src/app/store/fhir-feature/fhir.actions.ts b/ui/snapclient/src/app/store/fhir-feature/fhir.actions.ts index 1d7e86ba..144ebb11 100644 --- a/ui/snapclient/src/app/store/fhir-feature/fhir.actions.ts +++ b/ui/snapclient/src/app/store/fhir-feature/fhir.actions.ts @@ -35,12 +35,12 @@ export enum FhirActionTypes { LOOKUP_CONCEPT = "[Fhir] Lookup Concept", LOOKUP_CONCEPT_SUCCESS = "[Fhir] Lookup Concept Succeeded", LOOKUP_CONCEPT_FAILED = "[Fhir] Lookup Concept Failed", - LOOKUP_MODULE = "[Fhir] Lookup Module", - LOOKUP_MODULE_SUCCESS = "[Fhir] Lookup Module Succeeded", - LOOKUP_MODULE_FAILED = "[Fhir] Lookup Module Failed", + DISPLAY_RESOLVED_LOOKUP_CONCEPT = "[Fhir] Display Resolved Lookup Concept", + DISPLAY_RESOLVED_LOOKUP_CONCEPT_SUCCESS = "Display Resolved Lookup Concept Succeeded", + DISPLAY_RESOLVED_LOOKUP_CONCEPT_FAILED = "Display Resolved Lookup Concept Failed", CONCEPT_HIERARCHY = "[Fhir] Concept Hierarchy", CONCEPT_HIERARCHY_SUCCESS = "Concept Hierarchy Succeeded", - CONCEPT_HIERARCHY_FAILED = "Concept Hierarchy Failed" + CONCEPT_HIERARCHY_FAILED = "Concept Hierarchy Failed", } export class LoadReleases implements Action { @@ -106,7 +106,7 @@ export class AutoSuggestFailure implements Action { export class LookupConcept implements Action { readonly type = FhirActionTypes.LOOKUP_CONCEPT; - constructor(public payload: {code: string, system: string, version: string}) { + constructor(public payload: {code: string, system: string, version: string, properties: string[]}) { } } @@ -124,22 +124,22 @@ export class LookupConceptFailure implements Action { } } -export class LookupModule implements Action { - readonly type = FhirActionTypes.LOOKUP_MODULE; +export class DisplayResolvedLookupConcept implements Action { + readonly type = FhirActionTypes.DISPLAY_RESOLVED_LOOKUP_CONCEPT; - constructor(public payload: {code: string, system: string, version: string}) { + constructor(public payload: {code: string, system: string, version: string, properties: string[]}) { } } -export class LookupModuleSuccess implements Action { - readonly type = FhirActionTypes.LOOKUP_MODULE_SUCCESS; +export class DisplayResolvedLookupConceptSuccess implements Action { + readonly type = FhirActionTypes.DISPLAY_RESOLVED_LOOKUP_CONCEPT_SUCCESS; constructor(public payload: Properties) { } } -export class LookupModuleFailure implements Action { - readonly type = FhirActionTypes.LOOKUP_MODULE_FAILED; +export class DisplayResolvedLookupConceptFailure implements Action { + readonly type = FhirActionTypes.DISPLAY_RESOLVED_LOOKUP_CONCEPT_FAILED; constructor(public payload: { error: any }) { } @@ -178,9 +178,9 @@ export type FhirActions = LoadReleases | LookupConcept | LookupConceptSuccess | LookupConceptFailure - | LookupModule - | LookupModuleSuccess - | LookupModuleFailure + | DisplayResolvedLookupConcept + | DisplayResolvedLookupConceptSuccess + | DisplayResolvedLookupConceptFailure | ConceptHierarchy | ConceptHierarchySuccess | ConceptHierarchyFailure diff --git a/ui/snapclient/src/app/store/fhir-feature/fhir.effects.ts b/ui/snapclient/src/app/store/fhir-feature/fhir.effects.ts index 8882dd16..a9893655 100644 --- a/ui/snapclient/src/app/store/fhir-feature/fhir.effects.ts +++ b/ui/snapclient/src/app/store/fhir-feature/fhir.effects.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { of } from 'rxjs/internal/observable/of'; import { FhirService } from '../../_services/fhir.service'; import { R4 } from '@ahryman40k/ts-fhir-types'; @@ -33,15 +33,13 @@ import { AutoSuggestFailure, ConceptHierarchySuccess, ConceptHierarchyFailure, - LookupModuleSuccess, - LookupModuleFailure + DisplayResolvedLookupConceptSuccess, + DisplayResolvedLookupConceptFailure } from './fhir.actions'; import { Release } from '../../_services/fhir.service'; -import {Match} from './fhir.reducer'; import {TranslateService} from '@ngx-translate/core'; import {SnomedUtils} from 'src/app/_utils/snomed_utils'; -import { ObservableInput } from 'rxjs'; export interface Properties { [key: string]: any[] @@ -125,8 +123,34 @@ export class FhirEffects { case 'property': { if (part) { const partKey = part.find((sub: any) => sub?.name === 'code').valueCode; - const partValue = FhirEffects.getValue(part.find((sub: any) => sub.name?.startsWith('value'))); - FhirEffects.updateProps(props, partKey, [partValue]); + if ("609096000" === partKey) { // role group + const subproperties = part.filter((sub: any) => sub.name?.startsWith('subproperty')).map((subproperty: any) => subproperty.part) + .sort((a:any, b:any) => { + const aValueStr = a.filter((subproperty: any) => { + if (subproperty.name === "code" && subproperty.hasOwnProperty("valueString")) { + return subproperty; + } + }); + const bValueStr = b.filter((subproperty: any) => { + if (subproperty.name === "code" && subproperty.hasOwnProperty("valueString")) { + return subproperty; + } + }); + return aValueStr[0].valueString.localeCompare(bValueStr[0].valueString); + }); + FhirEffects.updateProps(props, "attributeRelationships", subproperties); + } + else if (!isNaN(+partKey)) { + part.filter((part: any) => part); + FhirEffects.updateProps(props, "attributeRelationships", part.filter((part: any) => part)); + } + else { + let partValue = FhirEffects.getValue(part.find((sub: any) => sub.name?.startsWith('valueString'))); + if (!partValue) { + partValue = FhirEffects.getValue(part.find((sub: any) => sub.name?.startsWith('value'))); + } + FhirEffects.updateProps(props, partKey, [partValue]); + } } break; } @@ -161,24 +185,25 @@ export class FhirEffects { return props; } - lookupModule$ = createEffect(() => this.actions$.pipe( - ofType(FhirActionTypes.LOOKUP_MODULE), - map(action => action.payload), - switchMap((action) => this.fhirService.lookupConcept(action.code, action.system, action.version).pipe( - map(parameters => this.mapParameters(parameters, action)), - switchMap((props) => of(new LookupModuleSuccess(props))), - catchError((err) => of(new LookupModuleFailure({ error: err }))) - ))), { dispatch: true }); lookupConcept$ = createEffect(() => this.actions$.pipe( ofType(FhirActionTypes.LOOKUP_CONCEPT), map(action => action.payload), - switchMap((action) => this.fhirService.lookupConcept(action.code, action.system, action.version).pipe( + switchMap((action) => this.fhirService.lookupConcept(action.code, action.system, action.version, action.properties).pipe( map(parameters => this.mapParameters(parameters, action)), switchMap((props) => of(new LookupConceptSuccess(props))), catchError((err) => of(new LookupConceptFailure({ error: err }))) ))), { dispatch: true }); + displayResolvedLookupConcept$ = createEffect(() => this.actions$.pipe( + ofType(FhirActionTypes.DISPLAY_RESOLVED_LOOKUP_CONCEPT), + map(action => action.payload), + switchMap((action) => this.fhirService.displayResolvedLookupConcept(action.code, action.system, action.version, action.properties).pipe( + map(parameters => this.mapParameters(parameters, action)), + switchMap((props) => of(new DisplayResolvedLookupConceptSuccess(props))), + catchError((err) => of(new DisplayResolvedLookupConceptFailure({ error: err }))) + ))), { dispatch: true }); + constructor( private actions$: Actions, private fhirService: FhirService, diff --git a/ui/snapclient/src/app/store/fhir-feature/fhir.reducer.ts b/ui/snapclient/src/app/store/fhir-feature/fhir.reducer.ts index 59f14f64..b9e8f0d6 100644 --- a/ui/snapclient/src/app/store/fhir-feature/fhir.reducer.ts +++ b/ui/snapclient/src/app/store/fhir-feature/fhir.reducer.ts @@ -38,19 +38,26 @@ export interface Match { } export interface IFhirState { - editionToVersionsMap : Map | undefined; + editionToVersionsMap: Map | undefined; matches?: R4.IValueSet_Expansion; nodes: ConceptNode[]; suggests?: Match[]; properties?: Properties; - moduleProperties?: Properties; + resolvedDisplayProperties? : Properties; errorMessage: any | null; + replacementSuggestions: { + sameAs: R4.IParameters, + replacedBy: R4.IParameters, + possiblyEquivalentTo: R4.IParameters, + alternative: R4.IParameters + } | null; } export const initialFhirState: IFhirState = { editionToVersionsMap: new Map(), nodes: [], - errorMessage: null + errorMessage: null, + replacementSuggestions: null }; export function fhirReducer(state = initialFhirState, action: FhirActions): IFhirState { @@ -112,17 +119,17 @@ export function fhirReducer(state = initialFhirState, action: FhirActions): IFhi errorMessage: action.payload.error }; - case FhirActionTypes.LOOKUP_MODULE_SUCCESS: + case FhirActionTypes.DISPLAY_RESOLVED_LOOKUP_CONCEPT_SUCCESS: return { ...state, - moduleProperties: action.payload, + resolvedDisplayProperties: action.payload, errorMessage: null }; - case FhirActionTypes.LOOKUP_MODULE_FAILED: + case FhirActionTypes.DISPLAY_RESOLVED_LOOKUP_CONCEPT_FAILED: return { ...state, - moduleProperties: undefined, + resolvedDisplayProperties: undefined, errorMessage: action.payload.error }; diff --git a/ui/snapclient/src/app/store/fhir-feature/fhir.selectors.ts b/ui/snapclient/src/app/store/fhir-feature/fhir.selectors.ts index 8d6c48ef..32612d28 100644 --- a/ui/snapclient/src/app/store/fhir-feature/fhir.selectors.ts +++ b/ui/snapclient/src/app/store/fhir-feature/fhir.selectors.ts @@ -45,9 +45,9 @@ export const selectConceptProperties = createSelector( (state: IFhirState) => state.properties ); -export const selectModuleProperties = createSelector( +export const selectDisplayResolvedConceptProperties = createSelector( selectModules, - (state: IFhirState) => state.moduleProperties + (state: IFhirState) => state.resolvedDisplayProperties ); export const selectFhirError = createSelector( diff --git a/ui/snapclient/src/app/store/mapping-feature/mapping.effects.ts b/ui/snapclient/src/app/store/mapping-feature/mapping.effects.ts index a372df6f..69483390 100644 --- a/ui/snapclient/src/app/store/mapping-feature/mapping.effects.ts +++ b/ui/snapclient/src/app/store/mapping-feature/mapping.effects.ts @@ -72,16 +72,17 @@ export class MappingEffects { map(p => ServiceUtils.extractIdFromHref(p._links.self.href, null))); } + const theMapping = cloneDeep(new_mapping.mapping); return forkJoin([pid, sid]).pipe( switchMap(([projectid, sourceid]) => { - return this.mapService.createMapping(new_mapping.mapping, projectid, sourceid).pipe( + return this.mapService.createMapping(theMapping, projectid, sourceid, new_mapping.dualMapMode).pipe( map((m) => { - new_mapping.mapping.id = ServiceUtils.extractIdFromHref(m._links.self.href, null); - new_mapping.mapping.source = m.source as Source; - if (new_mapping.mapping.project) { - new_mapping.mapping.project.mapcount = 1; + theMapping.id = ServiceUtils.extractIdFromHref(m._links.self.href, null); + theMapping.source = m.source as Source; + if (theMapping.project) { + theMapping.project.mapcount = 1; } - return new_mapping.mapping; + return theMapping; }), switchMap((mapping: Mapping) => of(new AddMappingSuccess(mapping))), catchError((err: any) => of(new AddMappingFailure(err))), @@ -98,11 +99,11 @@ export class MappingEffects { }), switchMap((result: ImportMappingFileResult) => [ new ImportMappingFileSuccess(result), - new AddMappingSuccess(new_mapping.mapping) + new AddMappingSuccess(theMapping) ]), catchError((err, mapping) => [ new ImportMappingFileFailure(err), - new AddMappingSuccess(new_mapping.mapping) + new AddMappingSuccess(theMapping) ]) ); }) @@ -229,8 +230,9 @@ export class MappingEffects { map((action) => action.payload), switchMap((payload) => { const context = payload.context; + const filter = cloneDeep(context.filter); return this.mapService.getMapView(payload.mapping, context.pageIndex, - context.pageSize, context.sortColumn, context.sortDir, context.filter).pipe( + context.pageSize, context.sortColumn, context.sortDir, filter).pipe( switchMap((mapView: MapViewResults) => of(new LoadMapViewSuccess(mapView))), catchError((error: any) => of(new LoadMapViewFailure(error))), ); @@ -282,6 +284,7 @@ function toMapping(mapDto: any, project?: Project): Mapping { mapping.project = project; } else { mapping.project = toProject(mapDto.project); + mapping.project.dualMapMode = mapDto.project.dualMapMode; mapping.project.owners = mapDto.owners.map(toUser); mapping.project.members = mapDto.members?.map(toUser) ?? []; mapping.project.guests = mapDto.guests?.map(toUser) ?? []; @@ -308,6 +311,7 @@ function toProject(project: any, loadProject?: boolean): Project { proj.id = project.id; proj.maps = (project.maps ?? []).map((m: any) => toMapping(m, project)); proj.mapcount = project.mapCount; + proj.dualMapMode = project.dualMapMode; if (loadProject) { const allOwners = Array.from(new Set(project.maps.flatMap((map: any) => map?.project?.owners).concat(project.owners))); diff --git a/ui/snapclient/src/app/store/task-feature/task.actions.ts b/ui/snapclient/src/app/store/task-feature/task.actions.ts index 466985cd..ade499ae 100644 --- a/ui/snapclient/src/app/store/task-feature/task.actions.ts +++ b/ui/snapclient/src/app/store/task-feature/task.actions.ts @@ -41,7 +41,9 @@ export class LoadTasksForMap implements Action { authPageSize: number | undefined, authCurrentPage: number | undefined reviewPageSize: number | undefined, - reviewCurrentPage: number | undefined + reviewCurrentPage: number | undefined, + reconcilePageSize: number | undefined, + reconcileCurrentPage: number | undefined }) { } } diff --git a/ui/snapclient/src/app/store/task-feature/task.effects.ts b/ui/snapclient/src/app/store/task-feature/task.effects.ts index cd0a577e..4dfebead 100644 --- a/ui/snapclient/src/app/store/task-feature/task.effects.ts +++ b/ui/snapclient/src/app/store/task-feature/task.effects.ts @@ -73,13 +73,22 @@ export class TaskEffects { switchMap((resp: TaskPage) => of(new LoadTasksSuccess(resp))), catchError((err) => of(new LoadTasksFailure({error: err}))) ) - return forkJoin([authTasks, reviewTasks]).pipe( - switchMap(([authTasks, reviewTasks]) => { - if (authTasks instanceof LoadTasksSuccess && reviewTasks instanceof LoadTasksSuccess) { + let reconcileTasks = this.taskService.getTasksByMapAndType(payload.id, TaskType.RECONCILE, payload.reconcilePageSize, payload.reconcileCurrentPage).pipe( + map((resp: TaskResults) => { + let tasks_conv: Task[] = resp._embedded.tasks.map((task: any) => TaskEffects.mapTaskFromPayload(task)); + let taskPage: TaskPageDetails = resp.page; + return new TaskPage(taskPage, tasks_conv); + }), + switchMap((resp: TaskPage) => of(new LoadTasksSuccess(resp))), + catchError((err) => of(new LoadTasksFailure({error: err}))) + ) + return forkJoin([authTasks, reviewTasks, reconcileTasks]).pipe( + switchMap(([authTasks, reviewTasks, reconcileTasks]) => { + if (authTasks instanceof LoadTasksSuccess && reviewTasks instanceof LoadTasksSuccess && reconcileTasks instanceof LoadTasksSuccess) { let taskPages: TaskPageForType[] = []; let tasks: Task[] = []; - taskPages = [{type: TaskType.AUTHOR, page: authTasks.payload}, {type: TaskType.REVIEW, page: reviewTasks.payload}]; - tasks = [...authTasks.payload.tasks, ...reviewTasks.payload.tasks]; + taskPages = [{type: TaskType.AUTHOR, page: authTasks.payload}, {type: TaskType.REVIEW, page: reviewTasks.payload}, {type: TaskType.RECONCILE, page: reconcileTasks.payload}]; + tasks = [...authTasks.payload.tasks, ...reviewTasks.payload.tasks, ...reconcileTasks.payload.tasks]; return of(new LoadAllTasksSuccess({ taskPages: taskPages, tasks: tasks })); } else { return EMPTY; diff --git a/ui/snapclient/src/app/task/assigned-work/assigned-work.component.html b/ui/snapclient/src/app/task/assigned-work/assigned-work.component.html index f9402740..328ae876 100644 --- a/ui/snapclient/src/app/task/assigned-work/assigned-work.component.html +++ b/ui/snapclient/src/app/task/assigned-work/assigned-work.component.html @@ -38,6 +38,27 @@
+ + + compare_arrows + {{'TASK.TAB_RECONCILE' | translate }} + + + +
+ +
+
+ checklist diff --git a/ui/snapclient/src/app/task/assigned-work/assigned-work.component.spec.ts b/ui/snapclient/src/app/task/assigned-work/assigned-work.component.spec.ts index e28e2404..3bba95ae 100644 --- a/ui/snapclient/src/app/task/assigned-work/assigned-work.component.spec.ts +++ b/ui/snapclient/src/app/task/assigned-work/assigned-work.component.spec.ts @@ -47,7 +47,7 @@ describe('AssignedWorkComponent', () => { mapping.project.title = 'Test Map'; const task = new Task('1', TaskType.AUTHOR, 'test', mapping, user, '1-10', 10, '', '', false, false); - const expectedTabLabels = ['add_taskTASK.ADD_TASK', 'editTASK.TAB_AUTHOR', 'checklistTASK.TAB_REVIEW']; + const expectedTabLabels = ['add_taskTASK.ADD_TASK', 'editTASK.TAB_AUTHOR', 'checklistTASK.TAB_REVIEW', 'compare_arrowsTASK.TAB_RECONCILE']; beforeEach(async () => { await TestBed.configureTestingModule({ diff --git a/ui/snapclient/src/app/task/assigned-work/assigned-work.component.ts b/ui/snapclient/src/app/task/assigned-work/assigned-work.component.ts index d312f7fe..5336e9f5 100644 --- a/ui/snapclient/src/app/task/assigned-work/assigned-work.component.ts +++ b/ui/snapclient/src/app/task/assigned-work/assigned-work.component.ts @@ -41,6 +41,7 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { private subscription = new Subscription(); authorTasks: Task[] | null | undefined; reviewTasks: Task[] | null | undefined; + reconcileTasks: Task[] | null | undefined; loading = true; currentUser: User = new User(); error: ErrorInfo = {}; @@ -56,8 +57,13 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { @Output() reviewPageSizeChange = new EventEmitter(); @Input() reviewCurrentPage: number | undefined; @Output() reviewCurrentPageChange = new EventEmitter(); + @Input() reconcilePageSize: number | undefined; + @Output() reconcilePageSizeChange = new EventEmitter(); + @Input() reconcileCurrentPage: number | undefined; + @Output() reconcileCurrentPageChange = new EventEmitter(); authTotalElements = 0; reviewTotalElements = 0; + reconcileTotalElements = 0; pageSizeOptions: number[] = [10, 25, 50, 100]; @Input() mapping: Mapping | undefined; @Input() mappingTableSelector: MappingTableSelectorComponent | null | undefined; @@ -112,16 +118,22 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { data => { let authPage = data.find(taskPage => taskPage.type === TaskType.AUTHOR); let reviewPage = data.find(taskPage => taskPage.type === TaskType.REVIEW); + let reconcilePage = data.find(taskPage => taskPage.type === TaskType.RECONCILE); self.authorTasks = authPage?.page.tasks .sort((a, b) => AssignedWorkComponent.sortTasks(a, b)); self.reviewTasks = reviewPage?.page.tasks .sort((a, b) => AssignedWorkComponent.sortTasks(a, b)); + self.reconcileTasks = reconcilePage?.page.tasks + .sort((a, b) => AssignedWorkComponent.sortTasks(a, b)); if (authPage?.page) { self.authTotalElements = authPage.page.page.totalElements; } if (reviewPage?.page) { self.reviewTotalElements = reviewPage.page.page.totalElements; } + if (reconcilePage?.page) { + self.reconcileTotalElements = reconcilePage.page.page.totalElements; + } self.loading = false; self.setTab(); }, @@ -146,8 +158,10 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { this.authCurrentPage = event.pageIndex; this.authPageSizeChange.emit(this.authPageSize); this.authCurrentPageChange.emit(this.authCurrentPage); - this.store.dispatch(new LoadTasksForMap({id: this.mapping?.id, authPageSize: this.authPageSize, - authCurrentPage: this.authCurrentPage, reviewPageSize: this.reviewPageSize, reviewCurrentPage: this.reviewCurrentPage})); + this.store.dispatch(new LoadTasksForMap({id: this.mapping?.id, + authPageSize: this.authPageSize, authCurrentPage: this.authCurrentPage, + reviewPageSize: this.reviewPageSize, reviewCurrentPage: this.reviewCurrentPage, + reconcilePageSize: this.reconcilePageSize, reconcileCurrentPage: this.reconcileCurrentPage})); } reviewPageChanged(event: PageEvent): void { @@ -155,8 +169,21 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { this.reviewCurrentPage = event.pageIndex; this.reviewCurrentPageChange.emit(this.reviewCurrentPage); this.reviewPageSizeChange.emit(this.reviewPageSize); - this.store.dispatch(new LoadTasksForMap({id: this.mapping?.id, authPageSize: this.authPageSize, - authCurrentPage: this.authCurrentPage, reviewPageSize: this.reviewPageSize, reviewCurrentPage: this.reviewCurrentPage})); + this.store.dispatch(new LoadTasksForMap({id: this.mapping?.id, + authPageSize: this.authPageSize, authCurrentPage: this.authCurrentPage, + reviewPageSize: this.reviewPageSize, reviewCurrentPage: this.reviewCurrentPage, + reconcilePageSize: this.reconcilePageSize, reconcileCurrentPage: this.reconcileCurrentPage})); + } + + reconcilePageChanged(event: PageEvent): void { + this.reconcilePageSize = event.pageSize; + this.reconcileCurrentPage = event.pageIndex; + this.reconcileCurrentPageChange.emit(this.reconcileCurrentPage); + this.reconcilePageSizeChange.emit(this.reconcilePageSize); + this.store.dispatch(new LoadTasksForMap({id: this.mapping?.id, + authPageSize: this.authPageSize, authCurrentPage: this.authCurrentPage, + reviewPageSize: this.reviewPageSize, reviewCurrentPage: this.reviewCurrentPage, + reconcilePageSize: this.reconcilePageSize, reconcileCurrentPage: this.reconcileCurrentPage})); } setTab(): void { @@ -166,6 +193,15 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { self.activeTab = 1; break; case TaskType.REVIEW: + if (this.mapping?.project.dualMapMode) { + self.activeTab = 3; + } + else { + self.activeTab = 2; + } + + break; + case TaskType.RECONCILE: self.activeTab = 2; break; default: @@ -196,6 +232,9 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { case TaskType.REVIEW: this.reviewCurrentPage = 0; break; + case TaskType.RECONCILE: + this.reconcileCurrentPage = 0; + break; } this.updateCurrentTaskPage.emit(this.selectedTaskType); this.updateTableEvent.emit(this.selectedTaskType); @@ -211,6 +250,9 @@ export class AssignedWorkComponent implements OnInit, AfterViewInit, OnDestroy { case 2: this.selectedTaskType = TaskType.REVIEW; break; + case 3: + this.selectedTaskType = TaskType.RECONCILE; + break; default: this.selectedTaskType = ''; } diff --git a/ui/snapclient/src/app/task/task-add/task-add.component.ts b/ui/snapclient/src/app/task/task-add/task-add.component.ts index 7100554d..f7d196d0 100644 --- a/ui/snapclient/src/app/task/task-add/task-add.component.ts +++ b/ui/snapclient/src/app/task/task-add/task-add.component.ts @@ -48,7 +48,7 @@ export class TaskAddComponent implements OnInit, AfterViewInit, OnDestroy { error: ErrorInfo = {}; task: Task | undefined = undefined; members: User[] = []; - type_options = [TaskType.AUTHOR, TaskType.REVIEW]; + type_options: TaskType[] = []; row_options = ['ALL', 'SELECTED']; assignRows = ''; isMember = false; @@ -81,9 +81,12 @@ export class TaskAddComponent implements OnInit, AfterViewInit, OnDestroy { self.subscription.add(self.store.select(selectSelectedRows).subscribe( (selectedRows) => { if (self.task) { - if (selectedRows.length > 0) { - const sourceIndexes = selectedRows.map(selected => selected.sourceIndex); - self.task.sourceRowSpecification = ServiceUtils.convertNumberArrayToRangeString(sourceIndexes); + if (this.mappingTableSelector?.isAllSelected) { + self.task.sourceRowSpecification = '*'; + } + else if (selectedRows.length > 0) { + const sourceIndexes = selectedRows.map(selected => selected.sourceIndex); + self.task.sourceRowSpecification = ServiceUtils.convertNumberArrayToRangeString(sourceIndexes); } else { self.assignRows = ''; self.task.sourceRowSpecification = ''; @@ -108,6 +111,7 @@ export class TaskAddComponent implements OnInit, AfterViewInit, OnDestroy { self.mapping = mapping; self.initTask(); self.loadMemberList(); + self.initTaskTypeOptions(self.mapping); } })); } @@ -132,8 +136,13 @@ export class TaskAddComponent implements OnInit, AfterViewInit, OnDestroy { updateSelectedRows(): void { if (this.mappingTableSelector && this.mappingTableSelector.selectedRows && this.task) { if (this.mappingTableSelector.selectedRows.length > 0) { - const sourceIndexes = this.mappingTableSelector.selectedRows.map(selected => selected.sourceIndex); - this.task.sourceRowSpecification = ServiceUtils.convertNumberArrayToRangeString(sourceIndexes); + if (this.mappingTableSelector.isAllSelected) { + this.task.sourceRowSpecification = '*'; + } + else { + const sourceIndexes = this.mappingTableSelector.selectedRows.map(selected => selected.sourceIndex); + this.task.sourceRowSpecification = ServiceUtils.convertNumberArrayToRangeString(sourceIndexes); + } } else { this.assignRows = ''; this.task.sourceRowSpecification = ''; @@ -144,7 +153,7 @@ export class TaskAddComponent implements OnInit, AfterViewInit, OnDestroy { initTask(): void { this.assignRows = ''; if (this.mapping && this.currentUser?.id) { - this.task = new Task('', '', '', + this.task = new Task('', TaskType.AUTHOR, '', this.mapping, this.currentUser, '', 0, '', '', false, false); } } @@ -160,6 +169,15 @@ export class TaskAddComponent implements OnInit, AfterViewInit, OnDestroy { } } + initTaskTypeOptions(mapping: Mapping): void { + if (mapping.project.dualMapMode) { + this.type_options = [TaskType.AUTHOR, TaskType.RECONCILE, TaskType.REVIEW]; + } + else { + this.type_options = [TaskType.AUTHOR, TaskType.REVIEW]; + } + } + updateDescription(dirty: boolean | null): void { // Default description only if none entered if (!dirty && this.task) { @@ -183,10 +201,10 @@ export class TaskAddComponent implements OnInit, AfterViewInit, OnDestroy { onSubmit(form: NgForm, $event: Event): void { const self = this; try { - if (self.currentUser && self.task && self.task.type !== '' && form.form.valid) { + if (self.currentUser && self.task && form.form.valid) { if (self.task.assignee && self.task.assignee.id !== '') { self.store.dispatch(new AddTask(self.task)); - self.newTaskEvent.emit(self.task?.type); + self.newTaskEvent.emit(self.task.type); self.mappingTableSelector?.clearAllSelectedRows(); } else { throwError('TASK.ASSIGNEE_NOT_SET'); diff --git a/ui/snapclient/src/app/task/task-create/task-create.component.ts b/ui/snapclient/src/app/task/task-create/task-create.component.ts index 21e861f3..519272b4 100644 --- a/ui/snapclient/src/app/task/task-create/task-create.component.ts +++ b/ui/snapclient/src/app/task/task-create/task-create.component.ts @@ -105,6 +105,9 @@ export class TaskCreateComponent implements OnInit { let type = ''; switch (this.data.task.type) { // Note Opposite offending task + case TaskType.RECONCILE: + this.translate.get('TASK.TYPE_RECONCILE').subscribe((msg) => type = msg); + break; case TaskType.REVIEW: this.translate.get('TASK.TYPE_AUTHOR').subscribe((msg) => type = msg); break; diff --git a/ui/snapclient/src/app/task/task-item/task-item.component.css b/ui/snapclient/src/app/task/task-item/task-item.component.css index 45bb4623..55ba50f9 100644 --- a/ui/snapclient/src/app/task/task-item/task-item.component.css +++ b/ui/snapclient/src/app/task/task-item/task-item.component.css @@ -15,3 +15,6 @@ .task-item.REVIEW { color: #043c36; } +.task-item.RECONCILE { + color: #3a0e30; +} diff --git a/ui/snapclient/src/app/task/task-item/task-item.component.html b/ui/snapclient/src/app/task/task-item/task-item.component.html index 6c37c59d..f913ded4 100644 --- a/ui/snapclient/src/app/task/task-item/task-item.component.html +++ b/ui/snapclient/src/app/task/task-item/task-item.component.html @@ -1,6 +1,7 @@ edit + compare_arrows checklist {{task.description | trim}} diff --git a/ui/snapclient/src/app/user/gravatar/gravatar.component.ts b/ui/snapclient/src/app/user/gravatar/gravatar.component.ts index d425551e..691a03b1 100644 --- a/ui/snapclient/src/app/user/gravatar/gravatar.component.ts +++ b/ui/snapclient/src/app/user/gravatar/gravatar.component.ts @@ -17,6 +17,8 @@ import { Component, Input, OnInit, ViewChild } from '@angular/core'; import {Md5} from 'ts-md5'; +const SESSION_STORAGE_FAILED_GRAVATARS_KEY = "failedGravatars"; + @Component({ selector: 'app-gravatar', templateUrl: './gravatar.component.html', @@ -93,6 +95,14 @@ export class GravatarComponent implements OnInit { handleError(): void { this.gravatar = false; + + // remember that this.src has failed previously .. stored for a session only + let failedGravatarsArray = this.getFailedGravatarsArray(); + if (failedGravatarsArray.indexOf(this.src) === -1) { + failedGravatarsArray.push(this.src); + sessionStorage.setItem(SESSION_STORAGE_FAILED_GRAVATARS_KEY, JSON.stringify(failedGravatarsArray)); + } + } updateGravatar(email?: string): void { @@ -104,6 +114,22 @@ export class GravatarComponent implements OnInit { } const emailHash = Md5.hashStr(email.trim().toLowerCase()); this.src = `//www.gravatar.com/avatar/${emailHash}?${this.param}`; + + // don't look up the image if it has failed previously + let failedGravatarsArray = this.getFailedGravatarsArray(); + if (failedGravatarsArray.indexOf(this.src) > -1) { + this.gravatar = false; + } + } + + getFailedGravatarsArray() : string[] { + let failedGravatarsStr = sessionStorage.getItem(SESSION_STORAGE_FAILED_GRAVATARS_KEY); + let failedGravatarsArray = []; + if (failedGravatarsStr) { + failedGravatarsArray = JSON.parse(failedGravatarsStr); + } + + return failedGravatarsArray; } } diff --git a/ui/snapclient/src/assets/i18n/en.json b/ui/snapclient/src/assets/i18n/en.json index b5365090..1d4214ea 100644 --- a/ui/snapclient/src/assets/i18n/en.json +++ b/ui/snapclient/src/assets/i18n/en.json @@ -23,12 +23,17 @@ "DIALOG_NOTE_DELETE": "DELETE NOTE", "DIALOG_NOTE_TITLE": "Delete Note", "NOTES": "Notes", + "SYSTEM_NOTES": "System Notes", "HAS_NOTES": "Source has notes", "SOURCE_DETAILS": "Source details", "TARGET_SEARCH_RESULTS": "Target search results", "TARGET_BY_RELATIONSHIP": "Target by relationship", "MAPPED_SOURCE_TO_TARGET": "Mapped Source to Target", "TARGET_PROPERTIES": "Target Properties", + "OUT_OF_SCOPE_USE_AUTHOR_TASK_TO_FIND_REPLACEMENTS": "Target out of scope - use the AUTHOR task to find suggested replacements", + "OUT_OF_SCOPE_USE_AUTHOR_RECONCILE_TASK_TO_FIND_REPLACEMENTS": "Target out of scope - use the AUTHOR or RECONCILE task to find suggested replacements", + "OUT_OF_SCOPE_FIND_REPLACEMENTS": "Target out of scope - click to find suggested replacements", + "OUT_OF_SCOPE_NO_SUGGESTED_REPLACEMENTS": "Target out of scope - no active in scope replacements suggested", "NOTE_DELETE": "Delete note", "NOTE_TEXT": "Add your notes here", "NOTE_TEXT_ERROR": "Notes text entry cannot start with a space", @@ -94,7 +99,11 @@ "NOT_AUTHORIZED_RETURN": "Return to Home", "EXPORT_FAILED": "Export Failed", "EXISTING_VERSION": "A map with the same version already exists", - "PROJECT_LOAD": "Project access may not be authorized" + "PROJECT_LOAD": "Project access may not be authorized", + "RECONCILE_NO_MAP_AND_TARGETS": "This map is not yet fully reconciled. No Map cannot be selected when targets are specified", + "RECONCILE_NO_NO_MAP_OR_TARGETS": "This map is not yet fully reconciled. Either No Map needs to be selected or targets should be specified", + "RECONCILE_SAME_TARGET_MULTIPLE_RELATIONSHIPS": "This map is not yet fully reconciled. The same target exists with multiple relationships", + "RECONCILE_DUPLICATE_TARGET": "This map is not yet fully reconciled. A target is duplicated" }, "FORM": { "CANCEL": "Cancel", @@ -112,7 +121,8 @@ "VIEW": "VIEW", "IMPORT_EXISTING_MAP": "Import existing map", "SELECT_FILE": "Select file", - "CLEAR_SELECTED_FILE": "Clear selected file" + "CLEAR_SELECTED_FILE": "Clear selected file", + "FHIR_METADATA": "Additional FHIR metadata (for FHIR ConceptMap export)" }, "MAP": { "BULK_CHANGE_TOOLTIP": "Perform bulk changes to selected rows", @@ -136,8 +146,11 @@ "MEMBERS": "Members", "OWNERS": "Owners", "GUESTS": "Guests", + "DUAL_MAP": "dual map", + "SINGLE_MAP": "single map", "MAP_EDIT_BUTTON": "EDIT", "MAP_VIEW_BUTTON": "VIEW", + "NUM_TARGETS_OUT_OF_SCOPE": "Number targets out of scope", "MAPPING": "MAPPING", "MARK_MAPPED": "Mark all as MAPPED", "MARK_ACCEPTED": "Mark all as ACCEPTED", @@ -190,6 +203,8 @@ "EXPORT_CSV": "Comma separated", "EXPORT_TSV": "Tab separated", "EXPORT_XLSX": "Excel (xlsx)", + "EXPORT_FHIR_JSON": "FHIR (json)", + "EXPORT_XLSX_EXTENDED": "Excel (xlsx) extended", "CODE_SYSTEMS": "Code systems", "LAST_UPDATED": "Last updated", "UNSAVED_CHANGES": "Warning: you have unsaved changes", @@ -198,7 +213,10 @@ "FAILED_TO_IMPORT_MAPPING_FILE_ERROR": "Details of error: {{error}}", "VALIDATE_TARGETS": "VALIDATE", "VALIDATE_TARGETS_TOOLTIP": "Flag all rows where the target code is outside the target scope", - "VALIDATE_TARGETS_ERROR": "Unexpected error occurred during validation of target scope" + "VALIDATE_TARGETS_ERROR": "Unexpected error occurred during validation of target scope", + "SINGLE_AUTHOR_MODE": "Single Author Mode", + "DUAL_AUTHOR_MODE": "Dual Author Mode", + "CLEARED_AND_REBLINDED_WARN": "Any rows undergoing dual mapping will be cleared and reblinded in the new version of a dual map" }, "TABLE": { "SOURCE_CODE": "Source code", @@ -208,24 +226,30 @@ "TARGET_CODE": "Target code", "TARGET_DISPLAY": "Target display", "STATUS": "Status", + "TARGET_OUT_OF_SCOPE": "Tags", + "TARGET_OUT_OF_SCOPE_TOOLTIP": "Target out of scope", "NO_MAP": "No map", "ACTIONS": "Actions", "DETAILS": "Details", "FLAG": "Flag", "NOTES": "Notes", "AUTHOR": "Assigned author", + "RECONCILER": "Assigned reconciler", "REVIEWER": "Assigned reviewer", "BULK_CHANGE": "BULK EDIT", + "LAST_AUTHOR": "Last author", "LAST_AUTHOR_REVIEWER": "Last author/ reviewer" }, "TASK": { "AUTHOR": "AUTHOR", - "REVIEW": "REVIEW", + "REVIEW": "REVIEW", + "RECONCILE": "RECONCILE", "ROWS_LABEL": "Source indexes:", "MY_TASKS": "My tasks", "ADD_TASK": "New Task", "TAB_AUTHOR": "Author", "TAB_REVIEW": "Review", + "TAB_RECONCILE": "Reconcile", "ASSIGN": "Assign", "ASSIGNED_WORK": "Assigned Tasks", "ASSIGNEE_NOT_SET": "Assignee for task is not set", @@ -236,6 +260,7 @@ "NOT_AVAILABLE": "You are not assigned to this task", "TYPE_AUTHOR": "Author", "TYPE_REVIEW": "Reviewer", + "TYPE_RECONCILE": "Reconciler", "TYPE_UNKNOWN": "Unknown", "TYPE_DUAL": "Dual Independent", "DESCRIPTION": "Task Description", @@ -254,10 +279,13 @@ "DEFAULT": "Map all rows", "AUTHOR_TASK_FOR_ALL_ROWS": "Author task for all source terms", "REVIEW_TASK_FOR_ALL_ROWS": "Review task for all source terms", + "RECONCILE_TASK_FOR_ALL_ROWS": "Reconcile task for all source terms", "AUTHOR_TASK_FOR_SELECTED_ROWS": "Author task for selected source terms", "REVIEW_TASK_FOR_SELECTED_ROWS": "Review task for selected source terms", + "RECONCILE_TASK_FOR_SELECTED_ROWS": "Reconcile task for selected source terms", "AUTHOR_TASK_FOR__ROWS": "Author task", "REVIEW_TASK_FOR__ROWS": "Review task", + "RECONCILE_TASK_FOR__ROWS": "Reconcile task", "_TASK_FOR_ALL_ROWS": "Task for all source terms", "_TASK_FOR_CUSTOM_ROWS": "Task for custom source term list", "_TASK_FOR_SELECTED_ROWS": "Task for selected source terms", @@ -313,6 +341,10 @@ "NAME_DUPLICATE": "Source name and version combination must be unique", "NAME_ERROR": "A source name is required", "NAME_PLACEHOLDER": "Provide a title or brief description", + "CODESYSTEM": "CodeSystem URI", + "CODESYSTEM_PLACEHOLDER": "The URI that identifies the CodeSystem containing these codes", + "VALUESET": "ValueSet URI", + "VALUESET_PLACEHOLDER": "The URI that identifies the ValueSet consisting of these codes", "NO_FILE": "No file selected", "SELECT_COLUMNS": "We have selected columns for source code and display to be used for mapping. Please check these are correct as they cannot be changed later.", "SOURCE": "Source", @@ -341,6 +373,7 @@ "DRAFT": "DRAFT", "MAPPED": "MAPPED", "INREVIEW": "IN REVIEW", + "RECONCILE": "RECONCILE", "ACCEPTED": "ACCEPTED", "REJECTED": "REJECTED", "SWITCH": "Switch to " @@ -414,6 +447,10 @@ "NO_MATCHES": "No search results", "INPUT_LABEL": "Text search" }, + "PROPERTIES": { + "TARGET_PROPERTIES": "Target Properties", + "ATTRIBUTE_RELATIONSHIPS": "Attribute Relationships" + }, "AUTOMAP": { "AUTOMAP": "AUTOMAP", "AUTOMAP_RUNNING": "Running automap", @@ -435,6 +472,7 @@ "CANNOT_OPEN_PROJECT_NO_ROLE": "Request a role from a project owner to open this project", "DELETE": "DELETE MAP", "DELETE_TOOLTIP": "Delete this map", + "DELETE_NOTE_TOOLTIP": "Delete this note", "TITLE_CONFIRM": "Delete Map", "CANCEL": "CANCEL", "CONFIRM_DELETE": "Are you sure you wish to delete this map? This will delete all versions of the map. Any mapping that has been performed will be removed and this cannot be undone." @@ -463,7 +501,9 @@ "ERROR_DEFAULT_MESSAGE": "Unexpected error occured during the Bulk change operation.", "SELECTED_TARGET": "Selected target:", "UPDATED_ROWS_MESSAGE": "{{updated}} out of {{selected}} rows have been updated.", - "UPDATED_ROWS_ERROR_MESSAGE": "{{updated}} out of {{selected}} rows have been updated. {{diff}} row(s) unable to be changed due to invalid status or relationship changes." + "UPDATED_ROWS_ERROR_MESSAGE": "{{updated}} out of {{selected}} rows have been updated. {{diff}} row(s) unable to be changed due to invalid status or relationship changes.", + "REDO_DUAL_MAPPING": "Clears targets and enables row to be dual mapped again", + "RECONCILE_STATUS_INFO": "To update any rows in a MAPPED, ACCEPTED or REJECTED state, change the row status to RECONCILE, then alter the row via the RECONCILE TASK" }, "NOTESDIALOG": { @@ -485,5 +525,14 @@ "IMPORT_SELECT_COLUMNS": "We have selected columns for import where possible. Please check these are correct as they cannot be changed later.", "IMPORT_HAS_HEADER": "The imported file has a header row", "IMPORT_COL_NOT_SPECIFIED": "Not Specified" + }, + + "FOOTER" : { + "COPYRIGHT_SNOMED_INTERNATIONAL": "Copyright 2023 SNOMED International", + "SNOMED_INTERNATIONAL": "SNOMED International", + "TERMS_OF_SERVICE": "Terms Of Service", + "PRIVACY_POLICY": "Privacy Policy", + "FEEDBACK": "Feedback", + "USER_GUIDE": "User Guide" } }