diff --git a/docs/processes/README.md b/docs/processes/README.md new file mode 100644 index 000000000..83607b10a --- /dev/null +++ b/docs/processes/README.md @@ -0,0 +1 @@ +diagrams have been created with: https://sequencediagram.org/ diff --git a/docs/processes/Sample_registration_process.svg b/docs/processes/Sample_registration_process.svg new file mode 100644 index 000000000..a5798c479 --- /dev/null +++ b/docs/processes/Sample_registration_process.svg @@ -0,0 +1 @@ +title%20Sample%20Registration%20Process%0A%0A%0Anote%20over%20Client%2C%20Template%20Service%3A%20Provide%20experiment%20ID%0AClient-%3ETemplate%20Service%3ARequest%20sample%20registration%20template%0ATemplate%20Service-%3EExperiment%20Service%3AFetch%20experimental%20groups%0AExperiment%20Service-%3EExperiment%20Service%3ALoad%20experimental%20groups%0ATemplate%20Service%3C-Experiment%20Service%3AReturn%20experimental%20groups%0ATemplate%20Service-%3EXLSXBuilder%3ABuild%20template%20with%20selection%20choices%0ATemplate%20Service%3C-XLSXBuilder%3AReturn%20template%0AClient%3C-Template%20Service%3A%20Return%20template%0AClient-%3EClient%3A%20Fill%20out%20template%0AClient-%3ESample%20Registration%20Service%3A%20Register%20samples%0ASample%20Registration%20Service-%3EValidation%20Service%3A%20Requests%20validation%0AValidation%20Service-%3EValidation%20Service%3A%20Validates%0ASample%20Registration%20Service%3C-Validation%20Service%3A%20Returns%20validation%20report%0AClient%3C-Sample%20Registration%20Service%3A%20Notify%20about%20report%0AClient-%3EClient%3A%20Resolve%20potential%20conflicts%0AClient-%3ESample%20Registration%20Service%3A%20Register%20sample%20batch%0ASample%20Registration%20Service-%3ESample%20Registration%20Service%3A%20Create%20new%20sample%20batch%0ASample%20Registration%20Service-%3ESample%20Registration%20Service%3A%20Create%20new%20samples%20with%20batch%20ID%0ASample%20Registration%20Service-%3ESample%20Registration%20Service%3A%20Update%20batch%20with%20sample%20IDs%0A%0AClient%3C-Sample%20Registration%20Service%3A%20NotifyClientTemplate ServiceExperiment ServiceXLSXBuilderSample Registration ServiceValidation ServiceSample Registration ProcessProvide experiment IDRequest sample registration templateFetch experimental groupsLoad experimental groupsReturn experimental groupsBuild template with selection choicesReturn templateReturn templateFill out templateRegister samplesRequests validationValidatesReturns validation reportNotify about reportResolve potential conflictsRegister sample batchCreate new sample batchCreate new samples with batch IDUpdate batch with sample IDsNotify diff --git a/docs/processes/Sample_registration_process.txt b/docs/processes/Sample_registration_process.txt new file mode 100644 index 000000000..cc304163a --- /dev/null +++ b/docs/processes/Sample_registration_process.txt @@ -0,0 +1,24 @@ +title Sample Registration Process + + +note over Client, Template Service: Provide experiment ID +Client->Template Service:Request sample registration template +Template Service->Experiment Service:Fetch experimental groups +Experiment Service->Experiment Service:Load experimental groups +Template Service<-Experiment Service:Return experimental groups +Template Service->XLSXBuilder:Build template with selection choices +Template Service<-XLSXBuilder:Return template +Client<-Template Service: Return template +Client->Client: Fill out template +Client->Sample Registration Service: Register samples +Sample Registration Service->Validation Service: Requests validation +Validation Service->Validation Service: Validates +Sample Registration Service<-Validation Service: Returns validation report +Client<-Sample Registration Service: Notify about report +Client->Client: Resolve potential conflicts +Client->Sample Registration Service: Register sample batch +Sample Registration Service->Sample Registration Service: Create new sample batch +Sample Registration Service->Sample Registration Service: Create new samples with batch ID +Sample Registration Service->Sample Registration Service: Update batch with sample IDs + +Client<-Sample Registration Service: Notify diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/ontology/TIBTerminologyServiceIntegration.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/ontology/TIBTerminologyServiceIntegration.java index ebfb4a523..6c693ae0d 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/ontology/TIBTerminologyServiceIntegration.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/ontology/TIBTerminologyServiceIntegration.java @@ -173,15 +173,11 @@ public List query(String searchTerm, int offset, int limit) @Override public Optional searchByCurie(String curie) throws LookupException { try { - List result = searchByOboId(curie, 0, 10); - if (result.isEmpty()) { - return Optional.empty(); - } - return Optional.of( - result.stream().map(TIBTerminologyServiceIntegration::convert).toList().get(0)); + return searchByOboIdExact(curie).map(TIBTerminologyServiceIntegration::convert); } catch (IOException e) { throw wrapIO(e); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); throw wrapInterrupted(e); } catch (Exception e) { throw wrapUnknown(e); @@ -221,9 +217,12 @@ private List fullSearch(String searchTerm, int offset, int limit) return List.of(); } HttpRequest termSelectQuery = HttpRequest.newBuilder().uri(URI.create( - searchEndpointAbsoluteUrl.toString() + "?q=" + URLEncoder.encode(searchTerm, - StandardCharsets.UTF_8) + "&rows=" - + limit + "&start=" + offset + "&ontology=" + createOntologyFilterQueryParameter())) + searchEndpointAbsoluteUrl.toString() + "?q=" + + URLEncoder.encode( + searchTerm, + StandardCharsets.UTF_8) + + "&rows=" + limit + "&start=" + offset + + "&ontology=" + createOntologyFilterQueryParameter())) .header("Content-Type", "application/json").GET().build(); var response = HTTP_CLIENT.send(termSelectQuery, BodyHandlers.ofString()); return parseResponse(response); @@ -278,15 +277,47 @@ private List searchByOboId(String oboId, int offset, int limit) return List.of(); } HttpRequest termSelectQuery = HttpRequest.newBuilder().uri(URI.create( - searchEndpointAbsoluteUrl.toString() + "?q=" + URLEncoder.encode(oboId, - StandardCharsets.UTF_8) + "&rows=" - + limit + "&start=" + offset + "&ontology=" + createOntologyFilterQueryParameter() - + "&queryFields=obo_id")) + searchEndpointAbsoluteUrl.toString() + "?q=" + + URLEncoder.encode( + //obo_id query field requires `:` separator instead of `_` + oboId.replace("_", ":"), StandardCharsets.UTF_8) + + "&queryFields=obo_id" + + "&rows=" + limit + "&start=" + offset + + "&ontology=" + createOntologyFilterQueryParameter())) .header("Content-Type", "application/json").GET().build(); var response = HTTP_CLIENT.send(termSelectQuery, BodyHandlers.ofString()); return parseResponse(response); } + /** + * Queries the /search endpoint of the TIB terminology service, but filters any results by the + * terms `obo_id` property. + *

+ * + * @param oboId the obo id to match exactly + * @return a list of matching terms. + * @throws IOException if e.g. the service cannot be reached + * @throws InterruptedException the query is interrupted before succeeding + * @since 1.4.0 + */ + private Optional searchByOboIdExact(String oboId) + throws IOException, InterruptedException { + if (oboId.isBlank()) { // avoid unnecessary API calls + return Optional.empty(); + } + HttpRequest termSelectQuery = HttpRequest.newBuilder().uri(URI.create( + searchEndpointAbsoluteUrl.toString() + "?q=" + + URLEncoder.encode( + //obo_id query field requires `:` separator instead of `_` + oboId.replace("_", ":"), StandardCharsets.UTF_8) + + "&queryFields=obo_id" + + "&exact=true" + + "&ontology=" + createOntologyFilterQueryParameter())) + .header("Content-Type", "application/json").GET().build(); + var response = HTTP_CLIENT.send(termSelectQuery, BodyHandlers.ofString()); + return parseResponse(response).stream().findFirst(); + } + /** * Parses the TIB service response object and returns the wrapped terms. * @@ -310,5 +341,3 @@ private List parseResponse(HttpResponse response) { } } } - - diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SamplePreviewJpaRepository.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SamplePreviewJpaRepository.java index dfb41e60f..5184c9a65 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SamplePreviewJpaRepository.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SamplePreviewJpaRepository.java @@ -3,13 +3,17 @@ import jakarta.persistence.criteria.Expression; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.Predicate; +import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import life.qbic.application.commons.OffsetBasedRequest; import life.qbic.application.commons.SortOrder; import life.qbic.projectmanagement.application.sample.SamplePreview; import life.qbic.projectmanagement.application.sample.SamplePreviewLookup; import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; +import life.qbic.projectmanagement.domain.model.sample.AnalysisMethod; import org.springframework.context.annotation.Scope; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; @@ -176,8 +180,13 @@ public static Specification analyteContains(String filter) { } public static Specification analysisMethodContains(String filter) { - return (root, query, builder) -> - builder.like(root.get("analysisMethod"), "%" + filter + "%"); + return (root, query, builder) -> { + Set matchingValues = Arrays.stream(AnalysisMethod.values()) + .filter(method -> method.label().toUpperCase().contains(filter.toUpperCase())) + .map(AnalysisMethod::abbreviation) + .collect(Collectors.toUnmodifiableSet()); + return root.get("analysisMethod").in(matchingValues); + }; } public static Specification commentContains(String filter) { diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SampleRepositoryImpl.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SampleRepositoryImpl.java index fc907da3e..84dadea8d 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SampleRepositoryImpl.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/sample/SampleRepositoryImpl.java @@ -16,9 +16,11 @@ import life.qbic.projectmanagement.domain.model.batch.BatchId; import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; import life.qbic.projectmanagement.domain.model.project.Project; +import life.qbic.projectmanagement.domain.model.project.ProjectId; import life.qbic.projectmanagement.domain.model.sample.Sample; import life.qbic.projectmanagement.domain.model.sample.SampleCode; import life.qbic.projectmanagement.domain.model.sample.SampleId; +import life.qbic.projectmanagement.domain.repository.ProjectRepository; import life.qbic.projectmanagement.domain.repository.SampleRepository; import life.qbic.projectmanagement.domain.service.SampleDomainService.ResponseCode; import life.qbic.projectmanagement.infrastructure.sample.openbis.OpenbisConnector.SampleNotDeletedException; @@ -48,12 +50,14 @@ public class SampleRepositoryImpl implements SampleRepository { private static final Logger log = logger(SampleRepositoryImpl.class); private final QbicSampleRepository qbicSampleRepository; private final QbicSampleDataRepo sampleDataRepo; + private final ProjectRepository projectRepository; @Autowired public SampleRepositoryImpl(QbicSampleRepository qbicSampleRepository, - QbicSampleDataRepo sampleDataRepo) { - this.qbicSampleRepository = qbicSampleRepository; - this.sampleDataRepo = sampleDataRepo; + QbicSampleDataRepo sampleDataRepo, ProjectRepository projectRepository) { + this.qbicSampleRepository = Objects.requireNonNull(qbicSampleRepository); + this.sampleDataRepo = Objects.requireNonNull(sampleDataRepo); + this.projectRepository = Objects.requireNonNull(projectRepository); } @Override @@ -61,21 +65,31 @@ public Result, ResponseCode> addAll(Project project, Collection samples) { String commaSeperatedSampleIds = buildCommaSeparatedSampleIds( samples.stream().map(Sample::sampleId).toList()); + List savedSamples; try { - this.qbicSampleRepository.saveAll(samples); + savedSamples = this.qbicSampleRepository.saveAll(samples); } catch (Exception e) { log.error("The samples:" + commaSeperatedSampleIds + "could not be saved", e); return Result.fromError(ResponseCode.REGISTRATION_FAILED); } try { - sampleDataRepo.addSamplesToProject(project, samples.stream().toList()); + sampleDataRepo.addSamplesToProject(project, savedSamples); } catch (Exception e) { log.error("The samples:" + commaSeperatedSampleIds + "could not be stored in openBIS", e); log.error("Removing samples from repository, as well."); - qbicSampleRepository.deleteAll(samples); + qbicSampleRepository.deleteAll(savedSamples); return Result.fromError(ResponseCode.REGISTRATION_FAILED); } - return Result.fromValue(samples); + return Result.fromValue(savedSamples); + } + + @Override + public Result, ResponseCode> addAll(ProjectId projectId, Collection samples) { + var projectQuery = projectRepository.find(projectId); + if (projectQuery.isPresent()) { + return addAll(projectQuery.get(), samples); + } + return Result.fromError(ResponseCode.REGISTRATION_FAILED); } private String buildCommaSeparatedSampleIds(Collection sampleIds) { @@ -100,7 +114,7 @@ public void deleteAll(Project project, @Override public boolean isSampleRemovable(SampleId sampleId) { - SampleCode sampleCode = qbicSampleRepository.findById(sampleId).get().sampleCode(); + SampleCode sampleCode = qbicSampleRepository.findById(sampleId).orElseThrow().sampleCode(); return sampleDataRepo.canDeleteSample(sampleCode); } @@ -134,6 +148,17 @@ public void updateAll(Project project, sampleDataRepo.updateAll(project, updatedSamples); } + @Transactional + @Override + public void updateAll(ProjectId projectId, Collection updatedSamples) { + var projectQuery = projectRepository.find(projectId); + if (projectQuery.isPresent()) { + updateAll(projectQuery.get(), updatedSamples); + } else { + throw new SampleRepositoryException("Could not find project with id " + projectId.value()); + } + } + @Override public List findSamplesBySampleId(List sampleId) { return qbicSampleRepository.findAllById(sampleId); diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java index 7268f8087..199476166 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java @@ -102,6 +102,13 @@ public Optional find(String projectId) throws IllegalArgumentException return find(ProjectId.parse(projectId)); } + @PreAuthorize("hasPermission(#projectId,'life.qbic.projectmanagement.domain.model.project.Project','READ')") + public Optional findOverview(ProjectId projectId) { + Objects.requireNonNull(projectId); + return projectOverviewLookup.query("", 0, 1, List.of(), List.of(projectId)).stream() + .findFirst(); + } + public boolean isProjectCodeUnique(String projectCode) throws IllegalArgumentException { return !projectRepository.existsProjectByProjectCode(ProjectCode.parse(projectCode)); } diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/ValidationException.java b/project-management/src/main/java/life/qbic/projectmanagement/application/ValidationException.java similarity index 88% rename from project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/ValidationException.java rename to project-management/src/main/java/life/qbic/projectmanagement/application/ValidationException.java index 4f9c7fe5a..ad4ed3fc7 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/ValidationException.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/ValidationException.java @@ -1,4 +1,4 @@ -package life.qbic.projectmanagement.application.measurement.validation; +package life.qbic.projectmanagement.application; public class ValidationException extends RuntimeException { diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/ValidationResult.java b/project-management/src/main/java/life/qbic/projectmanagement/application/ValidationResult.java similarity index 60% rename from project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/ValidationResult.java rename to project-management/src/main/java/life/qbic/projectmanagement/application/ValidationResult.java index 6c3676d8a..2e767d69a 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/ValidationResult.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/ValidationResult.java @@ -1,4 +1,4 @@ -package life.qbic.projectmanagement.application.measurement.validation; +package life.qbic.projectmanagement.application; import java.util.ArrayList; import java.util.Collection; @@ -16,43 +16,32 @@ */ public class ValidationResult { - private final int validatedEntries; private final List warnings; private final List failures; private ValidationResult() { - this.validatedEntries = 0; this.warnings = Collections.emptyList(); this.failures = Collections.emptyList(); } - private ValidationResult(int validatedEntries) { - this.validatedEntries = validatedEntries; - this.warnings = Collections.emptyList(); - this.failures = Collections.emptyList(); - } - - private ValidationResult(int validatedEntries, Collection warnings, + private ValidationResult(Collection warnings, Collection failures) { - this.validatedEntries = validatedEntries; this.warnings = warnings.stream().toList(); this.failures = failures.stream().toList(); } - public static ValidationResult successful(int validatedEntries) { - return new ValidationResult(validatedEntries); + public static ValidationResult successful() { + return new ValidationResult(); } - public static ValidationResult withFailures(int validatedEntries, - Collection failureReports) { - return new ValidationResult(validatedEntries, new ArrayList<>(), failureReports); + public static ValidationResult withFailures(Collection failureReports) { + return new ValidationResult(new ArrayList<>(), failureReports); } - public static ValidationResult successful(int validatedEntries, - Collection warnings) { - return new ValidationResult(validatedEntries, warnings, new ArrayList<>()); + public static ValidationResult successful(Collection warnings) { + return new ValidationResult(warnings, new ArrayList<>()); } @@ -79,20 +68,12 @@ public Collection failures() { return failures.stream().toList(); } - public int failedEntries() { - return failures.size(); - } - - public int validatedEntries() { - return validatedEntries; - } - public boolean containsWarnings() { return !warnings.isEmpty(); } public ValidationResult combine(ValidationResult otherResult) { - return new ValidationResult(this.validatedEntries + otherResult.validatedEntries, + return new ValidationResult( Stream.concat(this.warnings.stream(), otherResult.warnings.stream()).toList(), Stream.concat(this.failures.stream(), otherResult.failures.stream()).toList()); } diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/ValidationResultWithPayload.java b/project-management/src/main/java/life/qbic/projectmanagement/application/ValidationResultWithPayload.java new file mode 100644 index 000000000..c0bceed92 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/ValidationResultWithPayload.java @@ -0,0 +1,17 @@ +package life.qbic.projectmanagement.application; + +import java.util.Objects; + +/** + * + * + *

+ * + * @since + */ +public record ValidationResultWithPayload(ValidationResult validationResult, T payload) { + + public ValidationResultWithPayload { + Objects.requireNonNull(validationResult); + } +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/batch/BatchRegistrationService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/batch/BatchRegistrationService.java index e08f8ba6d..eee6a29fb 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/batch/BatchRegistrationService.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/batch/BatchRegistrationService.java @@ -89,6 +89,21 @@ public Result registerBatch(String label, boolean isPilot return Result.fromValue(result.getValue()); } + @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'WRITE')") + public Result addSamplesToBatch(Collection sampleIds, BatchId batchId, ProjectId projectId) { + var batchQuery = batchRepository.find(batchId); + if (batchQuery.isEmpty()) { + log.error("No batch with id found: " + batchId); + return Result.fromError(ResponseCode.BATCH_UPDATE_FAILED); + } + var batch = batchQuery.get(); + for (SampleId sampleId : sampleIds) { + batch.addSample(sampleId); + } + batchRepository.update(batch); + return Result.fromValue(batchId); + } + public Result addSampleToBatch(SampleId sampleId, BatchId batchId) { var random = new Random(); while (true) { @@ -197,6 +212,10 @@ public Result editBatch(BatchId batchId, String batchLabe return Result.fromValue(batch.batchId()); } + public void deleteBatch(BatchId batchId) { + batchRepository.deleteById(batchId); + } + private void dispatchSuccessfulBatchUpdate(BatchId batchId, ProjectId projectId) { BatchUpdated batchUpdated = BatchUpdated.create(batchId, projectId); DomainEventDispatcher.instance().dispatch(batchUpdated); diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/experiment/ExperimentInformationService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/experiment/ExperimentInformationService.java index 1358b06ca..8488a6e32 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/experiment/ExperimentInformationService.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/experiment/ExperimentInformationService.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -510,6 +511,13 @@ public Optional findProjectID(ExperimentId experimentId) { return id.map(ProjectId::parse); } + @PreAuthorize( + "hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'READ') ") + public List fetchGroups(String projectId, ExperimentId experimentId) { + return experimentRepository.find(experimentId).map(Experiment::getExperimentalGroups).orElse( + Collections.emptyList()); + } + /** * Information about an experimental group * diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidator.java b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidator.java index 5d6346441..dc6fe7851 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidator.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidator.java @@ -10,6 +10,8 @@ import java.util.regex.Pattern; import life.qbic.logging.api.Logger; import life.qbic.projectmanagement.application.ProjectInformationService; +import life.qbic.projectmanagement.application.ValidationException; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.application.measurement.MeasurementService; import life.qbic.projectmanagement.application.measurement.NGSMeasurementMetadata; import life.qbic.projectmanagement.application.ontology.TerminologyService; @@ -155,8 +157,8 @@ private class ValidationPolicy { ValidationResult validateMeasurementCode(String measurementCode) { var queryMeasurement = measurementService.findNGSMeasurement(measurementCode); - return queryMeasurement.map(measurement -> ValidationResult.successful(1)).orElse( - ValidationResult.withFailures(1, + return queryMeasurement.map(measurement -> ValidationResult.successful()).orElse( + ValidationResult.withFailures( List.of("Measurement ID: Unknown measurement for id '%s'".formatted(measurementCode)))); } @@ -172,23 +174,23 @@ ValidationResult validationProjectRelation(SampleCode sampleCode, ProjectId proj sampleIdCodeEntry -> sampleInformationService.findSample(sampleIdCodeEntry.sampleId())); if (sampleQuery.isEmpty()) { log.error("No sample information found for sample id: " + sampleCode); - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of("No sample information found for sample id: %s".formatted(sampleCode.code()))); } if (experimentIds.contains(sampleQuery.get().experimentId())) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of("Sample ID does not belong to this project: %s".formatted(sampleCode.code()))); } ValidationResult validateSampleIds(Collection sampleCodes) { if (sampleCodes.isEmpty()) { - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of("A measurement must contain at least one sample reference. Provided: none")); } ValidationResult validationResult = ValidationResult.successful( - 0); + ); for (SampleCode sample : sampleCodes) { validationResult = validationResult.combine(validateSampleId(sample)); } @@ -198,103 +200,103 @@ ValidationResult validateSampleIds(Collection sampleCodes) { ValidationResult validateSampleId(SampleCode sampleCode) { var queriedSampleEntry = sampleInformationService.findSampleId(sampleCode); if (queriedSampleEntry.isPresent()) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of(UNKNOWN_SAMPLE_MESSAGE.formatted(sampleCode.code()))); } ValidationResult validateOrganisation(String organisationId) { if (Pattern.compile(ROR_ID_REGEX).matcher(organisationId).find()) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of(UNKNOWN_ORGANISATION_ID_MESSAGE.formatted(organisationId))); } ValidationResult validateInstrument(String instrument) { var result = terminologyService.findByCurie(instrument); if (result.isPresent()) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of(UNKNOWN_INSTRUMENT_ID.formatted(instrument))); } ValidationResult validateMandatoryDataProvided( NGSMeasurementMetadata measurementMetadata) { - var validation = ValidationResult.successful(0); + var validation = ValidationResult.successful(); if (measurementMetadata.sampleCodes().isEmpty()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Sample id: missing sample id reference"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (measurementMetadata.organisationId().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Organisation: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (measurementMetadata.instrumentCURI().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Instrument: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (measurementMetadata.facility().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Facility: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (measurementMetadata.sequencingReadType().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Read Type: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } return validation; } ValidationResult validateMandatoryDataForUpdate(NGSMeasurementMetadata metadata) { - var validation = ValidationResult.successful(1); + var validation = ValidationResult.successful(); if (metadata.measurementIdentifier().isEmpty()) { - validation.combine(ValidationResult.withFailures(1, + validation.combine(ValidationResult.withFailures( List.of("Measurement id: missing measurement id for update"))); } else { - validation.combine(ValidationResult.successful(1)); + validation.combine(ValidationResult.successful()); } if (metadata.organisationId().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, List.of("Organisation: missing mandatory metadata"))); + ValidationResult.withFailures(List.of("Organisation: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.instrumentCURI().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, List.of("Instrument: missing mandatory metadata"))); + ValidationResult.withFailures(List.of("Instrument: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.facility().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, List.of("Facility: missing mandatory meta;data"))); + ValidationResult.withFailures(List.of("Facility: missing mandatory meta;data"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.sequencingReadType().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Read Type: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } return validation; } diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidator.java b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidator.java index fd0d065ee..fb736808c 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidator.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidator.java @@ -9,6 +9,8 @@ import java.util.regex.Pattern; import life.qbic.logging.api.Logger; import life.qbic.projectmanagement.application.ProjectInformationService; +import life.qbic.projectmanagement.application.ValidationException; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.application.measurement.MeasurementService; import life.qbic.projectmanagement.application.measurement.ProteomicsMeasurementMetadata; import life.qbic.projectmanagement.application.ontology.TerminologyService; @@ -213,153 +215,153 @@ ValidationResult validationProjectRelation(SampleCode sampleCode, ProjectId proj sampleIdCodeEntry -> sampleInformationService.findSample(sampleIdCodeEntry.sampleId())); if (sampleQuery.isEmpty()) { log.error("No sample information found for sample id: " + sampleCode); - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of("No sample information found for sample id: %s".formatted(sampleCode.code()))); } if (experimentIds.contains(sampleQuery.get().experimentId())) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of("Sample ID does not belong to this project: %s".formatted(sampleCode.code()))); } ValidationResult validateSampleId(SampleCode sampleCode) { var queriedSampleEntry = sampleInformationService.findSampleId(sampleCode); if (queriedSampleEntry.isPresent()) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of(UNKNOWN_SAMPLE_MESSAGE.formatted(sampleCode.code()))); } ValidationResult validateOrganisation(String organisationId) { if (Pattern.compile(ROR_ID_REGEX).matcher(organisationId).find()) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of(UNKNOWN_ORGANISATION_ID_MESSAGE.formatted(organisationId))); } ValidationResult validateMeasurementCode(String measurementCode) { var queryMeasurement = measurementService.findProteomicsMeasurement(measurementCode); - return queryMeasurement.map(measurement -> ValidationResult.successful(1)).orElse( - ValidationResult.withFailures(1, + return queryMeasurement.map(measurement -> ValidationResult.successful()).orElse( + ValidationResult.withFailures( List.of("Measurement Code: Unknown measurement for id '%s'".formatted(measurementCode)))); } ValidationResult validateMsDevice(String msDevice) { var result = terminologyService.findByCurie(msDevice); if (result.isPresent()) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of(UNKNOWN_MS_DEVICE_ID.formatted(msDevice))); } ValidationResult validateDigestionMethod(String digestionMethod) { if (DigestionMethod.isDigestionMethod(digestionMethod)) { - return ValidationResult.successful(1); + return ValidationResult.successful(); } - return ValidationResult.withFailures(1, + return ValidationResult.withFailures( List.of(UNKNOWN_DIGESTION_METHOD.formatted(digestionMethod))); } ValidationResult validateMandatoryDataForUpdate(ProteomicsMeasurementMetadata metadata) { - var validation = ValidationResult.successful(1); + var validation = ValidationResult.successful(); if (metadata.measurementIdentifier().isEmpty()) { - validation.combine(ValidationResult.withFailures(1, + validation.combine(ValidationResult.withFailures( List.of("Measurement id: missing measurement id for update"))); } else { - validation.combine(ValidationResult.successful(1)); + validation.combine(ValidationResult.successful()); } if (metadata.organisationId().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, List.of("Organisation: missing mandatory metadata"))); + ValidationResult.withFailures(List.of("Organisation: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.msDeviceCURIE().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, List.of("MS Device: missing mandatory metadata"))); + ValidationResult.withFailures(List.of("MS Device: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.facility().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, List.of("Facility: missing mandatory metadata"))); + ValidationResult.withFailures(List.of("Facility: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.digestionEnzyme().isBlank()) { - validation = validation.combine(ValidationResult.withFailures(1, + validation = validation.combine(ValidationResult.withFailures( List.of("Digestion Enzyme: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.digestionMethod().isBlank()) { - validation = validation.combine(ValidationResult.withFailures(1, + validation = validation.combine(ValidationResult.withFailures( List.of("Digestion Method: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.lcColumn().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, List.of("LC Column: missing mandatory metadata"))); + ValidationResult.withFailures(List.of("LC Column: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } return validation; } ValidationResult validateMandatoryDataProvided( ProteomicsMeasurementMetadata metadata) { - var validation = ValidationResult.successful(0); + var validation = ValidationResult.successful(); if (metadata.sampleCode() == null) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Sample id: missing sample id reference"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.organisationId().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Organisation: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.msDeviceCURIE().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("MS Device: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.facility().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("Facility: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.digestionEnzyme().isBlank()) { - validation = validation.combine(ValidationResult.withFailures(1, + validation = validation.combine(ValidationResult.withFailures( List.of("Digestion Enzyme: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.digestionMethod().isBlank()) { - validation = validation.combine(ValidationResult.withFailures(1, + validation = validation.combine(ValidationResult.withFailures( List.of("Digestion Method: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } if (metadata.lcColumn().isBlank()) { validation = validation.combine( - ValidationResult.withFailures(1, + ValidationResult.withFailures( List.of("LC Column: missing mandatory metadata"))); } else { - validation = validation.combine(ValidationResult.successful(1)); + validation = validation.combine(ValidationResult.successful()); } return validation; } diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidationService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidationService.java index fb26627e7..20f42e009 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidationService.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidationService.java @@ -3,6 +3,7 @@ import java.util.Collection; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.application.measurement.NGSMeasurementMetadata; import life.qbic.projectmanagement.application.measurement.ProteomicsMeasurementMetadata; import life.qbic.projectmanagement.domain.model.project.ProjectId; diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidator.java b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidator.java index 284f15ab6..349a88451 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidator.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/measurement/validation/MeasurementValidator.java @@ -1,5 +1,6 @@ package life.qbic.projectmanagement.application.measurement.validation; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.application.measurement.MeasurementMetadata; import life.qbic.projectmanagement.domain.model.project.ProjectId; diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/AnalysisMethodConverter.java b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/AnalysisMethodConverter.java new file mode 100644 index 000000000..4ea37b772 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/AnalysisMethodConverter.java @@ -0,0 +1,26 @@ +package life.qbic.projectmanagement.application.sample; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import life.qbic.projectmanagement.domain.model.sample.AnalysisMethod; + +@Converter(autoApply = true) +public class AnalysisMethodConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(AnalysisMethod attribute) { + if (attribute == null) { + return null; + } + return attribute.name(); + } + + @Override + public AnalysisMethod convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + return AnalysisMethod.valueOf(dbData); + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/PropertyConversion.java b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/PropertyConversion.java new file mode 100644 index 000000000..10073167f --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/PropertyConversion.java @@ -0,0 +1,125 @@ +package life.qbic.projectmanagement.application.sample; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; +import life.qbic.projectmanagement.domain.model.OntologyTerm; +import life.qbic.projectmanagement.domain.model.experiment.Condition; +import life.qbic.projectmanagement.domain.model.experiment.VariableLevel; + +/** + * Property to String conversion class + * + *

Centralises logic to control the String representation of properties for the parsing + * context.

+ * + * @since 1.5.0 + */ +public class PropertyConversion { + + private static final String CONDITION_VARIABLE_LEVEL_UNIT_TEMPLATE = "%s: %s %s"; // : [unit] + + private static final String CONDITION_VARIABLE_LEVEL_NO_UNIT_TEMPLATE = "%s: %s"; // : + + private static final String ONTOLOGY_TERM = "%s [%s]"; // [CURIE] + + private static final Pattern CURIE_PATTERN = Pattern.compile("\\[.*\\]"); + + /** + * Takes a {@link Condition} and transforms it into a String representation. + *

+ * In its current implementation, the String representation results into a + * ;-separated concatenation of {@link VariableLevel}. See + * {@link PropertyConversion#toString(VariableLevel)} for more details. + *

+ * Example: a condition with the two variable levels size: 20 cm and hue: + * blue will result in: + *

+ * size: 20cm; hue: blue + *

+ * The generalised form is:

+ * [var name 1]: [level value 1] [unit 1];...; [var name N]: [level value N] [unit N]; + * + * + * @param condition the condition object to transform + * @return the String representation + * @since 1.5.0 + */ + public static String toString(Condition condition) { + Objects.requireNonNull(condition); + List stringValues = new ArrayList<>(); + condition.getVariableLevels() + .forEach(variableLevel -> stringValues.add(toString(variableLevel))); + return String.join("; ", stringValues); + } + + /** + * Takes a {@link VariableLevel} and transforms it into a String representation. + *

+ * The generalised form of the output can be described with: + *

+ * [name]: [value] [unit] + * + * @param variableLevel the variable level to transform + * @return the String representation of the variable level + * @since 1.5.0 + */ + public static String toString(VariableLevel variableLevel) { + Objects.requireNonNull(variableLevel); + if (variableLevel.experimentalValue().unit().isPresent()) { + return CONDITION_VARIABLE_LEVEL_UNIT_TEMPLATE.formatted(variableLevel.variableName().value(), + variableLevel.experimentalValue().value(), + variableLevel.experimentalValue().unit().get()); + } else { + return CONDITION_VARIABLE_LEVEL_NO_UNIT_TEMPLATE.formatted( + variableLevel.variableName().value(), variableLevel.experimentalValue().value()); + } + } + + /** + * Transforms an {@link OntologyTerm} to its String representation. + *

+ * The String representation currently contains the term label and the OBO ID. + *

+ * The generalised form can be described as: + *

+ * [label] [[obo ID]] + *

+ * So e.g. 'Homo sapiens [NCBITaxon:9606]' + * + * @param ontologyTerm the ontology term to transform + * @return the String representation of the ontology term + * @since 1.5.0 + */ + public static String toString(OntologyTerm ontologyTerm) { + Objects.requireNonNull(ontologyTerm); + return ONTOLOGY_TERM.formatted(ontologyTerm.getLabel(), ontologyTerm.getOboId()); + } + + /** + * Expects a String that has been created with {@link #toString(OntologyTerm)} and tries to + * extract the CURIE of the ontology term. + *

+ * The CURIE is expected to be surrounded with [...] squared brackets. + * + * @param term the term that might contain the CURIE (e.g. OBO ID) + * @return an Optional that contains the found CURIE, or is empty else if none was found + * @since 1.5.0 + */ + public static Optional extractCURIE(String term) { + return CURIE_PATTERN.matcher(term).results().map(MatchResult::group).findFirst() + .map(PropertyConversion::sanitizeOntologyTerm); + } + + private static String removeBrackets(String value) { + return value.replace("[", "").replace("]", ""); + } + + private static String sanitizeOntologyTerm(String term) { + return removeBrackets(term).trim(); + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleMetadata.java b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleMetadata.java new file mode 100644 index 000000000..9cd1cde34 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleMetadata.java @@ -0,0 +1,67 @@ +package life.qbic.projectmanagement.application.sample; + +import java.util.Optional; +import life.qbic.projectmanagement.domain.model.OntologyTerm; +import life.qbic.projectmanagement.domain.model.sample.AnalysisMethod; +import life.qbic.projectmanagement.domain.model.sample.SampleId; + +/** + * Sample Metadata + * + *

A simple sample metadata DTO to pass them within the application

+ * + * @since 1.0.0 + */ +public record SampleMetadata( + SampleId sampleId, + String sampleCode, + String sampleName, + AnalysisMethod analysisToBePerformed, + String biologicalReplicate, + long experimentalGroupId, + OntologyTerm species, + OntologyTerm specimen, + OntologyTerm analyte, + String comment, + String experimentId +) { + + public static SampleMetadata createNew(String sampleName, + AnalysisMethod analysisToBePerformed, + String biologicalReplicate, + long experimentalGroupId, + OntologyTerm species, + OntologyTerm specimen, + OntologyTerm analyte, + String comment, + String experimentId) { + return new SampleMetadata(null, "", sampleName, analysisToBePerformed, biologicalReplicate, + experimentalGroupId, species, specimen, analyte, comment, experimentId); + } + + public static SampleMetadata createUpdate(SampleId sampleId, + String sampleCode, + String sampleName, + AnalysisMethod analysisToBePerformed, + String biologicalReplicate, + long experimentalGroupId, + OntologyTerm species, + OntologyTerm specimen, + OntologyTerm analyte, + String comment, + String experimentId) { + return new SampleMetadata(sampleId, sampleCode, sampleName, analysisToBePerformed, + biologicalReplicate, experimentalGroupId, species, specimen, analyte, comment, + experimentId); + } + + public Optional getSampleId() { + return Optional.ofNullable(sampleId); + } + + public Optional getSampleCode() { + return Optional.ofNullable(sampleCode.isBlank() ? null : sampleCode); + } + + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SamplePreview.java b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SamplePreview.java index a9294c59f..9d98b5fae 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SamplePreview.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SamplePreview.java @@ -15,6 +15,7 @@ import life.qbic.projectmanagement.domain.model.experiment.Experiment; import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; import life.qbic.projectmanagement.domain.model.experiment.ExperimentalGroup; +import life.qbic.projectmanagement.domain.model.sample.AnalysisMethod; import life.qbic.projectmanagement.domain.model.sample.Sample; import life.qbic.projectmanagement.domain.model.sample.SampleCode; import life.qbic.projectmanagement.domain.model.sample.SampleId; @@ -45,7 +46,8 @@ public class SamplePreview { private String biologicalReplicate; private String comment; @Column(name = "analysis_method") - private String analysisMethod; + private AnalysisMethod analysisMethod; + @OneToOne(cascade = CascadeType.ALL) @JoinColumn(name = "experimentalGroupId") private ExperimentalGroup experimentalGroup; @@ -60,7 +62,7 @@ protected SamplePreview() { private SamplePreview(ExperimentId experimentId, SampleId sampleId, String sampleCode, String batchLabel, String sampleName, String biologicalReplicate, ExperimentalGroup experimentalGroup, OntologyTerm species, - OntologyTerm specimen, OntologyTerm analyte, String analysisMethod, String comment) { + OntologyTerm specimen, OntologyTerm analyte, AnalysisMethod analysisMethod, String comment) { Objects.requireNonNull(experimentId); Objects.requireNonNull(sampleId); Objects.requireNonNull(sampleCode); @@ -114,7 +116,7 @@ public static SamplePreview create(ExperimentId experimentId, SampleId sampleId, String batchLabel, String sampleName, String biologicalReplicate, ExperimentalGroup experimentalGroup, OntologyTerm species, OntologyTerm specimen, OntologyTerm analyte, - String analysisMethod, String comment) { + AnalysisMethod analysisMethod, String comment) { return new SamplePreview(experimentId, sampleId, sampleCode, batchLabel, sampleName, biologicalReplicate, experimentalGroup, species, specimen, analyte, analysisMethod, comment); @@ -152,7 +154,7 @@ public OntologyTerm analyte() { return analyte; } - public String analysisMethod() { + public AnalysisMethod analysisMethod() { return analysisMethod; } diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleRegistrationServiceV2.java b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleRegistrationServiceV2.java new file mode 100644 index 000000000..9c9f2bde0 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleRegistrationServiceV2.java @@ -0,0 +1,175 @@ +package life.qbic.projectmanagement.application.sample; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import life.qbic.projectmanagement.application.DeletionService; +import life.qbic.projectmanagement.application.api.SampleCodeService; +import life.qbic.projectmanagement.application.batch.BatchRegistrationService; +import life.qbic.projectmanagement.domain.model.batch.Batch; +import life.qbic.projectmanagement.domain.model.batch.BatchId; +import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; +import life.qbic.projectmanagement.domain.model.project.ProjectId; +import life.qbic.projectmanagement.domain.model.sample.Sample; +import life.qbic.projectmanagement.domain.model.sample.SampleCode; +import life.qbic.projectmanagement.domain.model.sample.SampleId; +import life.qbic.projectmanagement.domain.model.sample.SampleOrigin; +import life.qbic.projectmanagement.domain.model.sample.SampleRegistrationRequest; +import life.qbic.projectmanagement.domain.repository.BatchRepository; +import life.qbic.projectmanagement.domain.repository.SampleRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +/** + * + * + *

+ * + * @since + */ +@Service +public class SampleRegistrationServiceV2 { + + private final BatchRegistrationService batchRegistrationService; + private final SampleRepository sampleRepository; + private final BatchRepository batchRepository; + private final SampleCodeService sampleCodeService; + private final DeletionService deletionService; + + @Autowired + public SampleRegistrationServiceV2(BatchRegistrationService batchRegistrationService, + SampleRepository sampleRepository, BatchRepository batchRepository, + SampleCodeService sampleCodeService, + DeletionService deletionService) { + this.batchRegistrationService = Objects.requireNonNull(batchRegistrationService); + this.sampleRepository = Objects.requireNonNull(sampleRepository); + this.batchRepository = Objects.requireNonNull(batchRepository); + this.sampleCodeService = Objects.requireNonNull(sampleCodeService); + this.deletionService = Objects.requireNonNull(deletionService); + } + + @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'WRITE')") + @Async + public CompletableFuture registerSamples(Collection sampleMetadata, + ProjectId projectId, String batchLabel, boolean batchIsPilot) + throws RegistrationException { + var result = batchRegistrationService.registerBatch(batchLabel, batchIsPilot, projectId); + if (result.isError()) { + throw new RegistrationException("Batch registration failed"); + } + var batchId = result.getValue(); + try { + var sampleIds = registerSamples(sampleMetadata, batchId, projectId); + batchRegistrationService.addSamplesToBatch(sampleIds, batchId, projectId); + } catch (RuntimeException e) { + rollbackSampleRegistration(batchId); + deletionService.deleteSamples(projectId, batchId, + sampleMetadata.stream().map(SampleMetadata::sampleId).toList()); + deletionService.deleteBatch(projectId, batchId); + throw e; + } + return CompletableFuture.completedFuture(null); + } + + @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'WRITE')") + @Async + public CompletableFuture updateSamples( + Collection sampleMetadata, + ProjectId projectId, + BatchId batchId, + String batchLabel, + boolean isPilot) + throws RegistrationException { + + Batch batch = batchRepository.find(batchId) + .orElseThrow(() -> new RegistrationException("Batch not found.")); + batch.setLabel(batchLabel); + batch.setPilot(isPilot); + + var sampleIds = sampleMetadata.stream() + .map(SampleMetadata::sampleId) + .toList(); + var samples = sampleRepository.findSamplesBySampleId(sampleIds); + var sampleBySampleCode = samples.stream() + .collect(Collectors.toMap(sample -> sample.sampleCode().code(), Function.identity())); + var updatedSamples = updateSamples(sampleBySampleCode, sampleMetadata); + sampleRepository.updateAll(projectId, updatedSamples); + batchRepository.update(batch); + return CompletableFuture.completedFuture(null); + } + + private List updateSamples(Map samples, + Collection sampleMetadataList) { + var updatedSamples = new ArrayList(); + for (SampleMetadata sampleMetadata : sampleMetadataList) { + var sampleForUpdate = samples.get(sampleMetadata.sampleCode()); + sampleForUpdate.setLabel(sampleMetadata.sampleName()); + sampleForUpdate.setAnalysisMethod(sampleMetadata.analysisToBePerformed()); + var sampleOrigin = SampleOrigin.create(sampleMetadata.species(), sampleMetadata.specimen(), + sampleMetadata.analyte()); + sampleForUpdate.setSampleOrigin(sampleOrigin); + sampleForUpdate.setBiologicalReplicate(sampleMetadata.biologicalReplicate()); + sampleForUpdate.setExperimentalGroupId(sampleMetadata.experimentalGroupId()); + sampleForUpdate.setComment(sampleMetadata.comment()); + updatedSamples.add(sampleForUpdate); + } + return updatedSamples; + } + + private Collection registerSamples(Collection sampleMetadata, + BatchId batchId, + ProjectId projectId) + throws RegistrationException { + var samplesToRegister = new ArrayList(); + var sampleCodes = generateSampleCodes(sampleMetadata.size(), projectId).iterator(); + for (SampleMetadata sample : sampleMetadata) { + samplesToRegister.add(buildSample(sample, batchId, sampleCodes.next())); + } + return sampleRepository.addAll(projectId, samplesToRegister) + .valueOrElseThrow(e -> new RegistrationException("Could not register samples: " + e.name())) + .stream().map(Sample::sampleId) + .toList(); + } + + private Sample buildSample(SampleMetadata sample, BatchId batchId, SampleCode sampleCode) { + var sampleOrigin = SampleOrigin.create(sample.species(), sample.specimen(), sample.analyte()); + return Sample.create(sampleCode, + new SampleRegistrationRequest(sample.sampleName(), sample.biologicalReplicate(), batchId, + ExperimentId.parse(sample.experimentId()), sample.experimentalGroupId(), sampleOrigin, + sample.analysisToBePerformed(), sample.comment())); + } + + private List generateSampleCodes(int amount, ProjectId projectId) { + var codes = new ArrayList(); + for (int i = 0; i < amount; i++) { + codes.add(sampleCodeService.generateFor(projectId).getValue()); + } + return codes; + } + + private void rollbackSampleRegistration(BatchId batchId) { + batchRegistrationService.deleteBatch(batchId); + } + + public static class RegistrationException extends RuntimeException { + + public RegistrationException(String message) { + super(message); + } + } + + public static class UnknownSampleException extends RuntimeException { + + public UnknownSampleException(String message) { + super(message); + } + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleValidation.java b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleValidation.java new file mode 100644 index 000000000..0e4258d24 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleValidation.java @@ -0,0 +1,337 @@ +package life.qbic.projectmanagement.application.sample; + +import static java.util.Objects.isNull; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import life.qbic.projectmanagement.application.ValidationResult; +import life.qbic.projectmanagement.application.ValidationResultWithPayload; +import life.qbic.projectmanagement.application.experiment.ExperimentInformationService; +import life.qbic.projectmanagement.application.ontology.SpeciesLookupService; +import life.qbic.projectmanagement.application.ontology.TerminologyService; +import life.qbic.projectmanagement.domain.model.OntologyTerm; +import life.qbic.projectmanagement.domain.model.experiment.Condition; +import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; +import life.qbic.projectmanagement.domain.model.experiment.ExperimentalGroup; +import life.qbic.projectmanagement.domain.model.sample.AnalysisMethod; +import life.qbic.projectmanagement.domain.model.sample.SampleCode; +import life.qbic.projectmanagement.domain.model.sample.SampleId; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * + * + *

+ * + * @since + */ +@Component +public class SampleValidation { + + private final SampleInformationService sampleInformationService; + + private final ExperimentInformationService experimentInformationService; + + private final TerminologyService terminologyService; + + private final SpeciesLookupService speciesLookupService; + + @Autowired + public SampleValidation(SampleInformationService sampleInformationService, + ExperimentInformationService experimentInformationService, + TerminologyService terminologyService, SpeciesLookupService speciesLookupService) { + this.sampleInformationService = Objects.requireNonNull(sampleInformationService); + this.experimentInformationService = Objects.requireNonNull(experimentInformationService); + this.terminologyService = Objects.requireNonNull(terminologyService); + this.speciesLookupService = Objects.requireNonNull(speciesLookupService); + } + + /** + * Creates a lookup table for conditions having their String representation as key. + *

+ * The String representation is done with the {@link PropertyConversion#toString(Condition)} + * method. + * + * @param conditions the conditions to take for the lookup table build + * @return the lookup table + * @since 1.5.0 + */ + private static Map conditionLookup( + List conditions) { + return conditions.stream() + .collect(Collectors.toMap(group -> PropertyConversion.toString(group.condition()), + Function.identity())); + } + + /** + * Validates metadata for a not yet registered sample. The validation does not look for any sample + * id and no sample information lookups are done in this case. + *

+ * If the client wants to validate the sample id as well, please refer to + * {@link SampleValidation#validateExistingSample(String, String, String, String, String, String, + * String, String, String, String, String)} + * + * @param sampleName the name of the sample + * @param biologicalReplicate the biological replicate + * @param condition the condition the sample was collected from + * @param species the species the sample was taken from + * @param specimen the specimen of the sample + * @param analyte the analyte that was extracted from the specimen + * @param analysisMethod the method applied on the analyte + * @param comment the comment associated with the sample + * @param experimentId the experiment id of the experiment the sample belongs to + * @param projectId the project id of project the experiment belongs to + * @return the report of the validation + * @since 1.5.0 + */ + public ValidationResultWithPayload validateNewSample(String sampleName, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String analysisMethod, + String comment, + String experimentId, + String projectId) { + + var experimentQuery = experimentInformationService.find(projectId, + ExperimentId.parse(experimentId)); + if (experimentQuery.isEmpty()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Unknown experiment.")), null); + } + var experiment = experimentQuery.orElseThrow(); + var experimentalGroupLookupTable = conditionLookup(experiment.getExperimentalGroups()); + + return validateForNewSample(sampleName, + biologicalReplicate, + condition, + species, + specimen, + analyte, + analysisMethod, + comment, + experimentId, + experimentalGroupLookupTable); + } + + private ValidationResultWithPayload validateForNewSample( + String sampleName, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String analysisMethod, + String comment, + String experimentId, + Map experimentalGroupLookupTable) { + + var sampleNameValidation = validateSampleName(sampleName); + var experimentalGroupValidation = validateExperimentalGroupForCondition(condition, + experimentalGroupLookupTable); + var analysisMethodValidation = validateAnalysisMethod(analysisMethod); + var speciesValidation = validateSpecies(species); + var specimenValidation = validateSpecimen(specimen); + var analyteValidation = validateAnalyte(analyte); + + ValidationResult combinedValidationResult = ValidationResult.successful() + .combine(sampleNameValidation.validationResult()) + .combine(experimentalGroupValidation.validationResult()) + .combine(analysisMethodValidation.validationResult()) + .combine(speciesValidation.validationResult()) + .combine(specimenValidation.validationResult()) + .combine(analyteValidation.validationResult()); + var metadata = combinedValidationResult.containsFailures() + ? null + : SampleMetadata.createNew( + sampleNameValidation.payload(), + analysisMethodValidation.payload(), + biologicalReplicate, + experimentalGroupValidation.payload(), + speciesValidation.payload(), + specimenValidation.payload(), + analyteValidation.payload(), + comment, + experimentId); + return new ValidationResultWithPayload<>(combinedValidationResult, metadata); + } + + private ValidationResultWithPayload validateSampleName(String sampleName) { + if (isNull(sampleName) || sampleName.isBlank()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Missing sample name.")), null); + } + return new ValidationResultWithPayload<>(ValidationResult.successful(), sampleName); + } + + private ValidationResultWithPayload validateExperimentalGroupForCondition(String condition, + Map conditionsLookupTable) { + if (isNull(condition) || condition.isBlank()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Missing condition")), null); + } + if (conditionsLookupTable.containsKey(condition)) { + return new ValidationResultWithPayload<>(ValidationResult.successful(), + conditionsLookupTable.get(condition).id()); + } else { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Unknown condition: " + condition)), null); + } + } + + private ValidationResultWithPayload validateAnalysisMethod( + String analysisMethod) { + if (analysisMethod == null || analysisMethod.isBlank()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("No analysis method provided.")), null); + } + return AnalysisMethod.forAbbreviation(analysisMethod) + .map(it -> new ValidationResultWithPayload<>(ValidationResult.successful(), it)) + .orElse(new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Unknown analysis: " + analysisMethod)), null)); + } + + private ValidationResultWithPayload validateSpecies(String species) { + var extractedTerm = PropertyConversion.extractCURIE(species); + if (extractedTerm.isEmpty()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Missing CURIE in species: " + species)), null); + } + var speciesLookup = speciesLookupService.findByCURI(extractedTerm.get()); + return speciesLookup + .map(OntologyTerm::from) + .map(it -> + new ValidationResultWithPayload<>(ValidationResult.successful(), it)) + .orElse(new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Unknown species: " + species)), null)); + } + + private ValidationResultWithPayload validateSpecimen(String specimen) { + var extractedTerm = PropertyConversion.extractCURIE(specimen); + if (extractedTerm.isEmpty()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Missing CURIE in specimen: " + specimen)), null); + } + var speciesLookup = terminologyService.findByCurie(extractedTerm.get()); + return speciesLookup + .map(it -> + new ValidationResultWithPayload<>(ValidationResult.successful(), it)) + .orElse(new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Unknown specimen: " + specimen)), null)); + } + + private ValidationResultWithPayload validateAnalyte(String analyte) { + var extractedTerm = PropertyConversion.extractCURIE(analyte); + if (extractedTerm.isEmpty()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Missing CURIE in analyte: " + analyte)), null); + } + var speciesLookup = terminologyService.findByCurie(extractedTerm.get()); + return speciesLookup + .map(it -> + new ValidationResultWithPayload<>(ValidationResult.successful(), it)) + .orElse(new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Unknown analyte: " + analyte)), null)); + } + + private ValidationResultWithPayload validateSampleIdForSampleCode(String sampleCode) { + if (sampleCode.isBlank()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Missing sample id.")), + null + ); + } + var sampleIdQuery = sampleInformationService.findSampleId(SampleCode.create(sampleCode)); + if (sampleIdQuery.isEmpty()) { + return new ValidationResultWithPayload<>(ValidationResult.withFailures(List.of( + "Unknown sample id: " + sampleCode)), null); + } + var sampleId = sampleIdQuery.orElseThrow().sampleId(); + return new ValidationResultWithPayload<>(ValidationResult.successful(), sampleId); + } + + /** + * Validates the metadata for a sample that has previously been registered. A registered sample + * has a sample code (sample id to the user) and an internal technical sample id. + *

+ * The method verifies the existence of a sample with the provided sample code and resolves it to + * the matching internal sample id. + *

+ * All other validation steps are equal to a + * {@link SampleValidation#validateNewSample(String, String, String, String, String, String, + * String, String, String, String)} call. + * + * @param sampleCode the sample code of the sample, known as sample id to the user + * @param condition the condition the sample was collected from + * @param species the species the sample was taken from + * @param specimen the specimen of the sample + * @param analyte the analyte that was extracted from the specimen + * @param analysisMethod the method applied on the analyte + * @param experimentId the experiment the sample belongs to + * @param projectId the project the sample belongs to + * @return a {@link ValidationResult} with detailed information about the validation + * @since 1.5.0 + */ + public ValidationResultWithPayload validateExistingSample(String sampleCode, + String sampleName, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String analysisMethod, + String comment, + String experimentId, + String projectId) { + + var experimentQuery = experimentInformationService.find(projectId, + ExperimentId.parse(experimentId)); + if (experimentQuery.isEmpty()) { + return new ValidationResultWithPayload<>( + ValidationResult.withFailures(List.of("Unknown experiment.")), null); + } + + var experiment = experimentQuery.orElseThrow(); + var experimentalGroupLookupTable = conditionLookup(experiment.getExperimentalGroups()); + + var sampleIdValidation = validateSampleIdForSampleCode(sampleCode); + var sampleNameValidation = validateSampleName(sampleName); + var experimentalGroupValidation = validateExperimentalGroupForCondition(condition, + experimentalGroupLookupTable); + var analysisMethodValidation = validateAnalysisMethod(analysisMethod); + var speciesValidation = validateSpecies(species); + var specimenValidation = validateSpecimen(specimen); + var analyteValidation = validateAnalyte(analyte); + + ValidationResult combinedValidationResult = ValidationResult.successful() + .combine(sampleIdValidation.validationResult()) + .combine(sampleNameValidation.validationResult()) + .combine(experimentalGroupValidation.validationResult()) + .combine(analysisMethodValidation.validationResult()) + .combine(speciesValidation.validationResult()) + .combine(specimenValidation.validationResult()) + .combine(analyteValidation.validationResult()); + var metadata = combinedValidationResult.containsFailures() + ? null + : SampleMetadata.createUpdate( + sampleIdValidation.payload(), + sampleCode, + sampleNameValidation.payload(), + analysisMethodValidation.payload(), + biologicalReplicate, + experimentalGroupValidation.payload(), + speciesValidation.payload(), + specimenValidation.payload(), + analyteValidation.payload(), + comment, + experimentId); + return new ValidationResultWithPayload<>(combinedValidationResult, metadata); + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleValidationService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleValidationService.java new file mode 100644 index 000000000..2fc8ba172 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/sample/SampleValidationService.java @@ -0,0 +1,132 @@ +package life.qbic.projectmanagement.application.sample; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import life.qbic.projectmanagement.application.ValidationResultWithPayload; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +/** + * + * + *

+ * + * @since + */ +@Service +public class SampleValidationService { + + private final SampleValidation sampleValidation; + + @Autowired + public SampleValidationService(SampleValidation sampleValidation) { + this.sampleValidation = Objects.requireNonNull(sampleValidation); + } + + @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'READ')") + public ValidationResultWithPayload validateNewSample( + String sampleName, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String analysisMethod, + String comment, + String experimentId, + String projectId) { + return sampleValidation.validateNewSample(sampleName, + biologicalReplicate, + condition, + species, + specimen, + analyte, + analysisMethod, + comment, + experimentId, + projectId); + } + + @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'READ')") + public ValidationResultWithPayload validateExistingSample( + String sampleCode, + String sampleName, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String analysisMethod, + String comment, + String experimentId, + String projectId) { + return sampleValidation.validateExistingSample(sampleCode, + sampleName, + biologicalReplicate, + condition, + species, + specimen, + analyte, + analysisMethod, + comment, + experimentId, + projectId); + } + + @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'READ')") + @Async + public CompletableFuture> validateNewSampleAsync( + String sampleName, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String analysisMethod, + String comment, + String experimentId, + String projectId) { + return CompletableFuture.completedFuture( + validateNewSample(sampleName, + biologicalReplicate, + condition, + species, + specimen, + analyte, + analysisMethod, + comment, + experimentId, + projectId)); + } + + @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'READ')") + @Async + public CompletableFuture> validateExistingSampleAsync( + String sampleCode, + String sampleName, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String analysisMethod, + String comment, + String experimentId, + String projectId) { + return CompletableFuture.completedFuture( + validateExistingSample(sampleCode, + sampleName, + biologicalReplicate, + condition, + species, + specimen, + analyte, + analysisMethod, + comment, + experimentId, + projectId)); + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/OntologyTerm.java b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/OntologyTerm.java index 3229185ac..922b57f5b 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/OntologyTerm.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/OntologyTerm.java @@ -4,6 +4,7 @@ import java.io.Serial; import java.io.Serializable; import java.util.Objects; +import java.util.StringJoiner; import life.qbic.projectmanagement.application.ontology.OntologyClass; import life.qbic.projectmanagement.domain.model.experiment.repository.jpa.OntologyClassAttributeConverter; @@ -178,4 +179,14 @@ public int hashCode() { result = 31 * result + (classIri != null ? classIri.hashCode() : 0); return result; } + + @Override + public String toString() { + return new StringJoiner(", ", OntologyTerm.class.getSimpleName() + "[", "]") + .add("ontologyAbbreviation='" + ontologyAbbreviation + "'") + .add("classLabel='" + classLabel + "'") + .add("oboId='" + oboId + "'") + .add("classIri='" + classIri + "'") + .toString(); + } } diff --git a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/AnalysisMethod.java b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/AnalysisMethod.java index d6d580398..50b0fb2c5 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/AnalysisMethod.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/AnalysisMethod.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; /** * Analysis method @@ -86,6 +87,8 @@ public enum AnalysisMethod { private static final Map labelToEnum; + private static final Map abbreviationToEnum; + static { labelToEnum = new HashMap<>(); for (AnalysisMethod method : AnalysisMethod.values()) { @@ -93,6 +96,13 @@ public enum AnalysisMethod { } } + static { + abbreviationToEnum = new HashMap<>(); + for (AnalysisMethod method : AnalysisMethod.values()) { + abbreviationToEnum.put(method.abbreviation(), method); + } + } + private final String abbreviation; private final String label; @@ -113,6 +123,14 @@ public static AnalysisMethod forLabel(String label) throws IllegalArgumentExcept } } + public static Optional forAbbreviation(String abbreviation) { + try { + return Optional.of(abbreviationToEnum.get(abbreviation)); + } catch (NullPointerException e) { + return Optional.empty(); + } + } + /** * Provides the abbreviation for a certain analysis method. *

diff --git a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/Sample.java b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/Sample.java index 2c20ea509..798a57414 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/Sample.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/sample/Sample.java @@ -178,6 +178,9 @@ static class AnalysisMethodConverter implements AttributeConverter, ResponseCode> addAll(Project project, Collection samples); + Result, ResponseCode> addAll(ProjectId projectId, Collection samples); + void deleteAll(Project project, Collection sampleIds); Result, SampleInformationService.ResponseCode> findSamplesByExperimentId( @@ -40,6 +43,8 @@ Result, SampleInformationService.ResponseCode> findSamplesByE void updateAll(Project project, Collection updatedSamples); + void updateAll(ProjectId projectId, Collection updatedSamples); + List findSamplesBySampleId(List sampleId); Optional findSample(SampleCode sampleCode); @@ -49,4 +54,11 @@ Result, SampleInformationService.ResponseCode> findSamplesByE boolean isSampleRemovable(SampleId sampleId); long countSamplesWithExperimentId(ExperimentId experimentId); + + class SampleRepositoryException extends RuntimeException { + public SampleRepositoryException(String message) { + super(message); + } + } + } diff --git a/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidatorSpec.groovy b/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidatorSpec.groovy index be66b6c40..baa2de6cf 100644 --- a/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidatorSpec.groovy +++ b/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementNGSValidatorSpec.groovy @@ -156,7 +156,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Unknown sample with sample id \"QNKWN001AE\"" } @@ -195,7 +194,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Unknown sample with sample id \"QNKWN001AE\"" } @@ -229,7 +227,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Sample id: missing sample id reference" } @@ -271,7 +268,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "The organisation ID does not seem to be a ROR ID: \"${invalidRorId}\"" where: @@ -316,7 +312,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Organisation: missing mandatory metadata" } @@ -394,7 +389,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Instrument: missing mandatory metadata" } @@ -471,7 +465,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Facility: missing mandatory metadata" } @@ -508,7 +501,6 @@ class MeasurementNGSValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Read Type: missing mandatory metadata" } } diff --git a/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidatorSpec.groovy b/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidatorSpec.groovy index 12b4bff77..c8730489c 100644 --- a/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidatorSpec.groovy +++ b/project-management/src/test/groovy/life/qbic/projectmanagement/application/measurement/validation/MeasurementProteomicsValidatorSpec.groovy @@ -163,7 +163,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Unknown sample with sample id \"QNKWN001AE\"" } @@ -200,7 +199,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Sample id: missing sample id reference" } @@ -245,7 +243,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "The organisation ID does not seem to be a ROR ID: \"${invalidRorId}\"" where: @@ -293,7 +290,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Organisation: missing mandatory metadata" } @@ -378,7 +374,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "MS Device: missing mandatory metadata" } @@ -462,7 +457,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Facility: missing mandatory metadata" } @@ -540,7 +534,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Digestion Enzyme: missing mandatory metadata" } @@ -580,7 +573,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "Digestion Method: missing mandatory metadata" } @@ -659,7 +651,6 @@ class MeasurementMeasurementProteomicsValidatorSpec extends Specification { !result.allPassed() !result.containsWarnings() result.containsFailures() - result.failedEntries() == 1 result.failures()[0] == "LC Column: missing mandatory metadata" } diff --git a/user-interface/frontend/themes/datamanager/components/dialog.css b/user-interface/frontend/themes/datamanager/components/dialog.css index 08134686e..521b329d4 100644 --- a/user-interface/frontend/themes/datamanager/components/dialog.css +++ b/user-interface/frontend/themes/datamanager/components/dialog.css @@ -503,13 +503,23 @@ Since we want to remove the spacing between the cancel and confirm button we rep font-weight: bold; } -.measurement-upload-dialog::part(overlay) { +.measurement-upload-dialog::part(overlay), +.register-samples-dialog::part(overlay), +.edit-samples-dialog::part(overlay) { min-width: 66%; min-height: 66%; max-height: 66%; max-width: 66%; } +.register-samples-dialog::part(overlay), +.edit-samples-dialog::part(overlay) { + min-width: 66%; + min-height: 80%; + max-height: 80%; + max-width: 66%; +} + .measurement-upload-dialog::part(content) { display: flex; flex-direction: column; @@ -528,12 +538,15 @@ Since we want to remove the spacing between the cancel and confirm button we rep gap: var(--lumo-space-s); } -.measurement-upload-dialog .upload-items-display .upload-section .restrictions { +.measurement-upload-dialog .upload-items-display .upload-section .restrictions, +.register-samples-dialog .restrictions, +.edit-samples-dialog .restrictions { font-size: smaller; display: inline-flex; justify-content: space-between; color: var(--lumo-contrast-60pct); column-gap: var(--lumo-space-s); + width: 100%; } .measurement-upload-dialog .upload-items-display .uploaded-items-section .section-title { @@ -546,17 +559,18 @@ Since we want to remove the spacing between the cancel and confirm button we rep font-size: var(--lumo-font-size-s); } -.measurement-upload-dialog .upload-items-display .uploaded-measurement-items { +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items, +.register-samples-dialog .uploaded-items-section, +.edit-samples-dialog .uploaded-items-section { border: 1px solid var(--lumo-contrast-20pct); border-radius: var(--lumo-border-radius-l); display: flex; flex-direction: column; } -.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item { - border-bottom-color: var(--lumo-contrast-20pct); - border-bottom-style: solid; - border-width: 1px; +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item, +.register-samples-dialog .uploaded-items-section .uploaded-item, +.edit-samples-dialog .uploaded-items-section .uploaded-item { font-size: var(--lumo-font-size-s); display: flex; padding-top: var(--lumo-space-m); @@ -566,20 +580,32 @@ Since we want to remove the spacing between the cancel and confirm button we rep row-gap: var(--lumo-space-m); } -.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .file-name { +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item { + border-bottom-color: var(--lumo-contrast-20pct); + border-bottom-style: solid; + border-width: 1px; +} + +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .file-name, +.register-samples-dialog .uploaded-items-section .uploaded-item .file-name, +.edit-samples-dialog .uploaded-items-section .uploaded-item .file-name { display: inline-flex; align-items: center; column-gap: var(--lumo-space-s); } -.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .file-icon { +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .file-icon, +.register-samples-dialog .uploaded-items-section .uploaded-item .file-icon, +.edit-samples-dialog .uploaded-items-section .uploaded-item .file-icon { display: inline-flex; flex-shrink: 0; font-size: smaller; color: var(--lumo-tertiary-text-color); } -.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .validation-display-box { +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .validation-display-box, +.register-samples-dialog .uploaded-items-section .uploaded-item .validation-display-box, +.edit-samples-dialog .uploaded-items-section .uploaded-item .validation-display-box { display: flex; flex-direction: column; row-gap: var(--lumo-space-s); @@ -590,7 +616,9 @@ Since we want to remove the spacing between the cancel and confirm button we rep border-radius: var(--lumo-border-radius-m); } -.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .validation-display-box .header { +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .validation-display-box .header, +.register-samples-dialog .uploaded-items-section .uploaded-item .validation-display-box .header, +.edit-samples-dialog .uploaded-items-section .uploaded-item .validation-display-box .header { font-size: small; align-items: center; display: inline-flex; @@ -598,7 +626,9 @@ Since we want to remove the spacing between the cancel and confirm button we rep font-weight: bold; } -.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .validation-display-box .invalid-measurement-list { +.measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item .validation-display-box .invalid-measurement-list, +.register-samples-dialog .uploaded-items-section .uploaded-item .validation-display-box .invalid-list, +.edit-samples-dialog .uploaded-items-section .uploaded-item .validation-display-box .invalid-list { padding-left: var(--lumo-space-m); } @@ -807,7 +837,70 @@ vaadin-upload-file::part(meta){ border-radius: var(--lumo-border-radius-s); } +.batch-name-field label, +.register-samples-dialog .section-title, +.edit-samples-dialog .section-title { + /*font-size: var(--lumo-font-size-l);*/ + font-weight: bold; + color: var(--lumo-secondary-text-color); +} + +.register-samples-dialog .download-metadata-section-content, +.edit-samples-dialog .download-metadata-section-content { + border: 1px solid var(--lumo-contrast-20pct); + border-radius: var(--lumo-border-radius-l); + display: flex; + flex-direction: row; + padding: 1em; + align-content: baseline; + display: flex; + align-items: center; +} + +.edit-samples-dialog .initial-view, +.register-samples-dialog .initial-view { + display: grid; + row-gap: 1em; +} + .validation-display-box { display: grid; } + +.in-progress-view, +.failed-view, +.succeeded-view { + display: grid; + row-gap: var(--lumo-space-l); +} + +.content-part > .title { + font-size: larger; + font-weight: bold; + color: var(--lumo-secondary-text-color); +} + +.content-part > .body { + +} + +.content-part > .body.success-box { + display: flex; + flex-wrap: nowrap; + align-items: center; + column-gap: var(--lumo-space-s); +} + +.content-part > .body.error-box { + display: flex; + flex-wrap: nowrap; + align-items: center; + column-gap: var(--lumo-space-s); +} + +.content-part[highlighted] { + border: solid var(--lumo-contrast-20pct) 2px; + border-radius: var(--lumo-border-radius-l); + padding: var(--lumo-space-m); +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/Application.java b/user-interface/src/main/java/life/qbic/datamanager/Application.java index 03f1342cb..9762004fb 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/Application.java +++ b/user-interface/src/main/java/life/qbic/datamanager/Application.java @@ -34,7 +34,7 @@ offlineResources = {"images/logo.png"}) @NpmPackage(value = "line-awesome", version = "1.3.0") @ComponentScan(value = {"life.qbic"}) -@Push(value = PushMode.MANUAL, transport = Transport.LONG_POLLING) +@Push(value = PushMode.AUTOMATIC, transport = Transport.LONG_POLLING) public class Application extends SpringBootServletInitializer implements AppShellConfigurator { private static final Logger log = LoggerFactory.logger(Application.class.getName()); diff --git a/user-interface/src/main/java/life/qbic/datamanager/ClientDetailsProviderImpl.java b/user-interface/src/main/java/life/qbic/datamanager/ClientDetailsProviderImpl.java index 4120bc58e..d3915fa99 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/ClientDetailsProviderImpl.java +++ b/user-interface/src/main/java/life/qbic/datamanager/ClientDetailsProviderImpl.java @@ -4,13 +4,11 @@ import com.vaadin.flow.component.page.Page.ExtendedClientDetailsReceiver; import java.util.Optional; import org.springframework.stereotype.Component; -import org.springframework.web.context.annotation.SessionScope; /** * Receives client details from vaadin and provides them internally. */ @Component -@SessionScope public class ClientDetailsProviderImpl implements ExtendedClientDetailsReceiver, ClientDetailsProvider { diff --git a/user-interface/src/main/java/life/qbic/datamanager/download/DownloadContentProvider.java b/user-interface/src/main/java/life/qbic/datamanager/download/DownloadContentProvider.java new file mode 100644 index 000000000..909ae8f63 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/download/DownloadContentProvider.java @@ -0,0 +1,40 @@ +package life.qbic.datamanager.download; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import org.apache.poi.ss.usermodel.Workbook; + +/** + * Provides content and file name for any files created from data and metadata. + */ +public interface DownloadContentProvider { + + public byte[] getContent(); + public String getFileName(); + + class XLSXDownloadContentProvider implements DownloadContentProvider { + + private final String fileName; + private final Workbook workbook; + + public XLSXDownloadContentProvider(String fileName, Workbook workbook) { + this.fileName = fileName; + this.workbook = workbook; + } + + @Override + public byte[] getContent() { + try (ByteArrayOutputStream arrayOutputStream = new ByteArrayOutputStream()) { + workbook.write(arrayOutputStream); + return arrayOutputStream.toByteArray(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getFileName() { + return fileName; + } + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/download/DownloadProvider.java b/user-interface/src/main/java/life/qbic/datamanager/download/DownloadProvider.java similarity index 96% rename from user-interface/src/main/java/life/qbic/datamanager/views/general/download/DownloadProvider.java rename to user-interface/src/main/java/life/qbic/datamanager/download/DownloadProvider.java index 4574c6ea1..5ef495e9e 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/download/DownloadProvider.java +++ b/user-interface/src/main/java/life/qbic/datamanager/download/DownloadProvider.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.views.general.download; +package life.qbic.datamanager.download; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.html.Anchor; diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/MeasurementMetadataConverter.java b/user-interface/src/main/java/life/qbic/datamanager/parser/MeasurementMetadataConverter.java index bc7e3ecae..5356b50ef 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/parser/MeasurementMetadataConverter.java +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/MeasurementMetadataConverter.java @@ -25,17 +25,18 @@ public interface MeasurementMetadataConverter { * * * @param parsingResult the parsing result to take as input for the conversion. - * @param ignoreMeasurementId weather to ignore the measurement identifier or not * @return a list of converted implementations of {@link MeasurementMetadata}. * @throws UnknownMetadataTypeException if no matching implementation of * {@link MeasurementMetadata} can be associated from the - * provided {@link ParsingResult#keys()}. + * provided {@link ParsingResult#columnMap()}. * @since 1.4.0 */ - List convert(ParsingResult parsingResult, - boolean ignoreMeasurementId) + List convertRegister(ParsingResult parsingResult) throws UnknownMetadataTypeException; + List convertEdit(ParsingResult parsingResult) + throws UnknownMetadataTypeException, MissingSampleIdException; + class UnknownMetadataTypeException extends RuntimeException { public UnknownMetadataTypeException(String message) { diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/MetadataConverter.java b/user-interface/src/main/java/life/qbic/datamanager/parser/MetadataConverter.java index 2bda75faf..0e7993cf9 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/parser/MetadataConverter.java +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/MetadataConverter.java @@ -10,11 +10,13 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; -import life.qbic.datamanager.parser.ParsingResult.Row; +import life.qbic.datamanager.parser.measurement.NGSMeasurementEditColumn; +import life.qbic.datamanager.parser.measurement.NGSMeasurementRegisterColumn; +import life.qbic.datamanager.parser.measurement.ProteomicsMeasurementEditColumn; +import life.qbic.datamanager.parser.measurement.ProteomicsMeasurementRegisterColumn; import life.qbic.logging.api.Logger; import life.qbic.projectmanagement.application.measurement.Labeling; import life.qbic.projectmanagement.application.measurement.MeasurementMetadata; @@ -31,8 +33,8 @@ * Currently supported metadata properties cover: * *

    - *
  • Proteomics Measurement {@link ProteomicsMeasurementProperty}
  • - *
  • NGS Measurement {@link NGSMeasurementProperty}
  • + *
  • Proteomics Measurement {@link ProteomicsMeasurementEditColumn}
  • + *
  • NGS Measurement {@link NGSMeasurementEditColumn}
  • *
* * @since 1.4.0 @@ -81,19 +83,31 @@ private static Map countHits(Collection target, Set convertRegister(ParsingResult parsingResult) + throws UnknownMetadataTypeException, MissingSampleIdException { + Objects.requireNonNull(parsingResult); + var properties = parsingResult.columnMap().keySet(); + if (looksLikeNgsMeasurement(properties, true)) { + return tryConversion(this::convertNewNGSMeasurement, parsingResult); + } else if (looksLikeProteomicsMeasurement(properties, true)) { + return tryConversion(this::convertNewProteomicsMeasurement, parsingResult); + } else { + throw new UnknownMetadataTypeException( + "Unknown metadata type: cannot match properties to any known metadata type. Provided [%s]".formatted( + String.join(", ", properties))); + } } @Override - public List convert(ParsingResult parsingResult, boolean ignoreMeasurementId) + public List convertEdit(ParsingResult parsingResult) throws UnknownMetadataTypeException, MissingSampleIdException { Objects.requireNonNull(parsingResult); - var properties = parsingResult.keys().keySet(); - if (looksLikeNgsMeasurement(properties, ignoreMeasurementId)) { - return tryConversion(this::convertNGSMeasurement, parsingResult); - } else if (looksLikeProteomicsMeasurement(properties, ignoreMeasurementId)) { - return tryConversion(this::convertProteomicsMeasurement, parsingResult); + var properties = parsingResult.columnMap().keySet(); + if (looksLikeNgsMeasurement(properties, false)) { + return tryConversion(this::convertExistingNGSMeasurement, parsingResult); + } else if (looksLikeProteomicsMeasurement(properties, false)) { + return tryConversion(this::convertExistingProteomicsMeasurement, parsingResult); } else { throw new UnknownMetadataTypeException( "Unknown metadata type: cannot match properties to any known metadata type. Provided [%s]".formatted( @@ -110,127 +124,223 @@ private List tryConversion( } } - private List convertProteomicsMeasurement(ParsingResult parsingResult) { + private List convertNewProteomicsMeasurement(ParsingResult parsingResult) { var result = new ArrayList(); - var keyIndices = parsingResult.keys(); - for (ParsingResult.Row row : parsingResult.rows()) { - // we us -1 as default value if a property cannot be accessed, thus ending up in an empty String - var pxpMetaDatum = new ProteomicsMeasurementMetadata( - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.MEASUREMENT_ID.propertyName(), - -1), - ""), - SampleCode.create( - safeListAccess(row.values(), - keyIndices.getOrDefault( - ProteomicsMeasurementProperty.QBIC_SAMPLE_ID.propertyName(), -1), - "")), - safeListAccess(row.values(), - keyIndices.getOrDefault( - ProteomicsMeasurementProperty.TECHNICAL_REPLICATE_NAME.propertyName(), -1), ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.ORGANISATION_ID.propertyName(), - -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.MS_DEVICE.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault( - ProteomicsMeasurementProperty.SAMPLE_POOL_GROUP.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.FACILITY.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.CYCLE.propertyName(), -1), ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.DIGESTION_ENZYME.propertyName(), - -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.DIGESTION_METHOD.propertyName(), - -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault( - ProteomicsMeasurementProperty.ENRICHMENT_METHOD.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.INJECTION_VOLUME.propertyName(), - -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.LC_COLUMN.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.LCMS_METHOD.propertyName(), -1), - ""), - new Labeling( - safeListAccess(row.values(), - keyIndices.getOrDefault( - ProteomicsMeasurementProperty.LABELING_TYPE.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.LABEL.propertyName(), -1), - "")), - safeListAccess(row.values(), - keyIndices.getOrDefault(ProteomicsMeasurementProperty.COMMENT.propertyName(), -1), "") - ); - result.add(pxpMetaDatum); + for (int i = 0; i < parsingResult.rows().size(); i++) { + var sampleCode = SampleCode.create(parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.SAMPLE_ID.headerName(), "")); + var technicalReplicateName = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.TECHNICAL_REPLICATE_NAME.headerName(), ""); + var organisationId = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.ORGANISATION_ID.headerName(), ""); + var msDevice = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.MS_DEVICE.headerName(), ""); + var samplePoolGroup = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.POOL_GROUP.headerName(), ""); + var facility = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.FACILITY.headerName(), ""); + var fractionName = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.CYCLE_FRACTION_NAME.headerName(), ""); + var digestionEnzyme = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.DIGESTION_ENZYME.headerName(), ""); + var digestionMethod = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.DIGESTION_METHOD.headerName(), ""); + var enrichmentMethod = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.ENRICHMENT_METHOD.headerName(), ""); + var injectionVolume = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.INJECTION_VOLUME.headerName(), ""); + var lcColumn = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.LC_COLUMN.headerName(), ""); + var lcmsMethod = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.LCMS_METHOD.headerName(), ""); + var labelingType = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.LABELING_TYPE.headerName(), ""); + var label = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.LABEL.headerName(), ""); + var comment = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementRegisterColumn.COMMENT.headerName(), ""); + var pxpMetaDaturm = new ProteomicsMeasurementMetadata( + "", + sampleCode, + technicalReplicateName, + organisationId, + msDevice, + samplePoolGroup, + facility, + fractionName, + digestionEnzyme, + digestionMethod, + enrichmentMethod, + injectionVolume, + lcColumn, + lcmsMethod, + new Labeling(labelingType, label), + comment); + result.add(pxpMetaDaturm); + } + return result; + } + + private List convertExistingProteomicsMeasurement( + ParsingResult parsingResult) { + var result = new ArrayList(); + for (int i = 0; i < parsingResult.rows().size(); i++) { + var measurementId = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.MEASUREMENT_ID.headerName(), ""); + var sampleCode = SampleCode.create(parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.SAMPLE_ID.headerName(), "")); + var technicalReplicateName = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.TECHNICAL_REPLICATE_NAME.headerName(), ""); + var organisationId = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.ORGANISATION_ID.headerName(), ""); + var msDevice = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.MS_DEVICE.headerName(), ""); + var samplePoolGroup = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.POOL_GROUP.headerName(), ""); + var facility = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.FACILITY.headerName(), ""); + var fractionName = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.CYCLE_FRACTION_NAME.headerName(), ""); + var digestionEnzyme = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.DIGESTION_ENZYME.headerName(), ""); + var digestionMethod = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.DIGESTION_METHOD.headerName(), ""); + var enrichmentMethod = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.ENRICHMENT_METHOD.headerName(), ""); + var injectionVolume = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.INJECTION_VOLUME.headerName(), ""); + var lcColumn = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.LC_COLUMN.headerName(), ""); + var lcmsMethod = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.LCMS_METHOD.headerName(), ""); + var labelingType = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.LABELING_TYPE.headerName(), ""); + var label = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.LABEL.headerName(), ""); + var comment = parsingResult.getValueOrDefault(i, + ProteomicsMeasurementEditColumn.COMMENT.headerName(), ""); + var pxpMetaDaturm = new ProteomicsMeasurementMetadata(measurementId, + sampleCode, + technicalReplicateName, + organisationId, + msDevice, + samplePoolGroup, + facility, + fractionName, + digestionEnzyme, + digestionMethod, + enrichmentMethod, + injectionVolume, + lcColumn, + lcmsMethod, + new Labeling(labelingType, label), + comment); + result.add(pxpMetaDaturm); } return result; } - private String safeListAccess(List list, Integer index, String defaultValue) { - if (index >= list.size() || index < 0) { - return defaultValue; + private List convertExistingNGSMeasurement(ParsingResult parsingResult) { + var result = new ArrayList(); + + for (int i = 0; i < parsingResult.rows().size(); i++) { + var measurementId = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.MEASUREMENT_ID.headerName(), ""); + var sampleCodes = List.of( + SampleCode.create( + parsingResult.getValueOrDefault(i, NGSMeasurementEditColumn.SAMPLE_ID.headerName(), + "")) + ); + var organisationId = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.ORGANISATION_ID.headerName(), ""); + var instrument = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.INSTRUMENT.headerName(), ""); + var facility = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.FACILITY.headerName(), ""); + var sequencingReadType = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.SEQUENCING_READ_TYPE.headerName(), ""); + var libraryKit = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.LIBRARY_KIT.headerName(), ""); + var flowCell = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.FLOW_CELL.headerName(), ""); + var runProtocol = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.SEQUENCING_RUN_PROTOCOL.headerName(), ""); + var poolGroup = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.POOL_GROUP.headerName(), ""); + var indexI7 = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.INDEX_I7.headerName(), ""); + var indexI5 = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.INDEX_I5.headerName(), ""); + var comment = parsingResult.getValueOrDefault(i, + NGSMeasurementEditColumn.COMMENT.headerName(), ""); + var metadatum = new NGSMeasurementMetadata( + measurementId, + sampleCodes, + organisationId, + instrument, + facility, + sequencingReadType, + libraryKit, + flowCell, + runProtocol, + poolGroup, + indexI7, + indexI5, + comment + ); + result.add(metadatum); } - return list.get(index); + return result; } - private List convertNGSMeasurement(ParsingResult parsingResult) { + private List convertNewNGSMeasurement(ParsingResult parsingResult) { var result = new ArrayList(); - var keyIndices = parsingResult.keys(); - for (Row row : parsingResult.rows()) { - var ngsMeasurementMetadata = new NGSMeasurementMetadata( - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.MEASUREMENT_ID.propertyName(), -1), - ""), - List.of(SampleCode.create( - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.QBIC_SAMPLE_ID.propertyName(), -1), - ""))), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.ORGANISATION_ID.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.INSTRUMENT.propertyName(), -1), ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.FACILITY.propertyName(), -1), ""), - safeListAccess(row.values(), keyIndices.getOrDefault( - NGSMeasurementProperty.SEQUENCING_READ_TYPE.propertyName(), -1), ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.LIBRARY_KIT.propertyName(), -1), ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.FLOW_CELL.propertyName(), -1), - ""), - safeListAccess(row.values(), keyIndices.getOrDefault( - NGSMeasurementProperty.SEQUENCING_RUN_PROTOCOL.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.SAMPLE_POOL_GROUP.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.INDEX_I7.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.INDEX_I5.propertyName(), -1), - ""), - safeListAccess(row.values(), - keyIndices.getOrDefault(NGSMeasurementProperty.COMMENT.propertyName(), -1), "") + + for (int i = 0; i < parsingResult.rows().size(); i++) { + var sampleCodes = List.of( + SampleCode.create( + parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.SAMPLE_ID.headerName(), + "")) + ); + var organisationId = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.ORGANISATION_ID.headerName(), ""); + var instrument = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.INSTRUMENT.headerName(), ""); + var facility = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.FACILITY.headerName(), ""); + var sequencingReadType = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.SEQUENCING_READ_TYPE.headerName(), ""); + var libraryKit = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.LIBRARY_KIT.headerName(), ""); + var flowCell = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.FLOW_CELL.headerName(), ""); + var runProtocol = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.SEQUENCING_RUN_PROTOCOL.headerName(), ""); + var poolGroup = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.POOL_GROUP.headerName(), ""); + var indexI7 = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.INDEX_I7.headerName(), ""); + var indexI5 = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.INDEX_I5.headerName(), ""); + var comment = parsingResult.getValueOrDefault(i, + NGSMeasurementRegisterColumn.COMMENT.headerName(), ""); + var metadatum = new NGSMeasurementMetadata( + "", + sampleCodes, + organisationId, + instrument, + facility, + sequencingReadType, + libraryKit, + flowCell, + runProtocol, + poolGroup, + indexI7, + indexI5, + comment ); - result.add(ngsMeasurementMetadata); + result.add(metadatum); } return result; } @@ -240,16 +350,17 @@ private boolean looksLikeNgsMeasurement(Collection properties, boolean i .collect(Collectors.toList()); Map hitMap; if (ignoreID) { - formattedProperties.remove(NGSMeasurementProperty.MEASUREMENT_ID.propertyName()); hitMap = countHits(formattedProperties, - Arrays.stream(NGSMeasurementProperty.values()) - .map(NGSMeasurementProperty::propertyName).collect( - Collectors.toSet()), NGSMeasurementProperty.MEASUREMENT_ID.propertyName()); + Arrays.stream(NGSMeasurementRegisterColumn.values()) + .map(NGSMeasurementRegisterColumn::headerName) + .map(Sanitizer::headerEncoder) + .collect(Collectors.toSet()), NGSMeasurementEditColumn.MEASUREMENT_ID.headerName()); } else { hitMap = countHits(formattedProperties, - Arrays.stream(NGSMeasurementProperty.values()) - .map(NGSMeasurementProperty::propertyName).collect( - Collectors.toSet())); + Arrays.stream(NGSMeasurementEditColumn.values()) + .map(NGSMeasurementEditColumn::headerName) + .map(Sanitizer::headerEncoder) + .collect(Collectors.toSet())); } var missingProperties = new ArrayList<>(); for (Entry entry : hitMap.entrySet()) { @@ -270,16 +381,18 @@ private boolean looksLikeProteomicsMeasurement(Collection properties, bo .collect(Collectors.toList()); Map hitMap; if (ignoreID) { - formattedProperties.remove(ProteomicsMeasurementProperty.MEASUREMENT_ID.propertyName()); hitMap = countHits(formattedProperties, - Arrays.stream(ProteomicsMeasurementProperty.values()) - .map(ProteomicsMeasurementProperty::propertyName).collect( - Collectors.toSet()), ProteomicsMeasurementProperty.MEASUREMENT_ID.propertyName()); + Arrays.stream(ProteomicsMeasurementRegisterColumn.values()) + .map(ProteomicsMeasurementRegisterColumn::headerName) + .map(Sanitizer::headerEncoder) + .collect(Collectors.toSet()), + ProteomicsMeasurementEditColumn.MEASUREMENT_ID.headerName()); } else { hitMap = countHits(formattedProperties, - Arrays.stream(ProteomicsMeasurementProperty.values()) - .map(ProteomicsMeasurementProperty::propertyName).collect( - Collectors.toSet())); + Arrays.stream(ProteomicsMeasurementEditColumn.values()) + .map(ProteomicsMeasurementEditColumn::headerName) + .map(Sanitizer::headerEncoder) + .collect(Collectors.toSet())); } var missingProperties = new ArrayList<>(); for (Entry entry : hitMap.entrySet()) { @@ -294,96 +407,4 @@ private boolean looksLikeProteomicsMeasurement(Collection properties, bo } return false; } - - - enum ProteomicsMeasurementProperty { - MEASUREMENT_ID("measurement id"), - TECHNICAL_REPLICATE_NAME("technical replicate"), - QBIC_SAMPLE_ID("qbic sample id"), - SAMPLE_POOL_GROUP("sample pool group"), - ORGANISATION_ID("organisation id"), - FACILITY("facility"), - MS_DEVICE("ms device"), - CYCLE("cycle/fraction name"), - DIGESTION_METHOD("digestion method"), - DIGESTION_ENZYME("digestion enzyme"), - ENRICHMENT_METHOD("enrichment method"), - INJECTION_VOLUME("injection volume (µl)"), - LC_COLUMN("lc column"), - LCMS_METHOD("lcms method"), - LABELING_TYPE("labeling type"), - LABEL("label"), - COMMENT("comment"); - - private final String name; - - ProteomicsMeasurementProperty(String value) { - this.name = value; - } - - static Optional fromString(String value) { - var sanitizedValue = sanitizeValue(value); - return Arrays.stream(ProteomicsMeasurementProperty.values()) - .filter(property -> property.propertyName().equals(sanitizedValue)).findFirst(); - } - - static boolean valueMatchesAnyProperty(String value) { - var sanitizedValue = sanitizeValue(value); - return Arrays.stream(ProteomicsMeasurementProperty.values()) - .map(ProteomicsMeasurementProperty::name) - .anyMatch(sanitizedValue::equalsIgnoreCase); - } - - public String propertyName() { - return name; - } - - } - - enum NGSMeasurementProperty { - MEASUREMENT_ID("measurement id"), - QBIC_SAMPLE_ID("qbic sample id"), - ORGANISATION_ID("organisation id"), - SAMPLE_POOL_GROUP("sample pool group"), - FACILITY("facility"), - INSTRUMENT("instrument"), - SEQUENCING_READ_TYPE("sequencing read type"), - LIBRARY_KIT("library kit"), - FLOW_CELL("flow cell"), - SEQUENCING_RUN_PROTOCOL("sequencing run protocol"), - INDEX_I7("index i7"), - INDEX_I5("index i5"), - COMMENT("comment"); - - private final String name; - - NGSMeasurementProperty(String value) { - this.name = value; - } - - /** - * Tries to convert an input property value to a known {@link NGSMeasurementProperty}. - *

- * Trailing whitespace will be ignored. - * - * @param value the presumed value to convert to a known {@link NGSMeasurementProperty} - * @return the matching property, or {@link Optional#empty()}. - * @since 1.4.0 - */ - static Optional fromStringTrimmed(String value) { - var sanitizedValue = sanitizeValue(value); - return Arrays.stream(NGSMeasurementProperty.values()) - .filter(property -> property.propertyName().equalsIgnoreCase(sanitizedValue)).findFirst(); - } - - static boolean valueMatchesAnyProperty(String value) { - var sanitizedValue = sanitizeValue(value); - return Arrays.stream(NGSMeasurementProperty.values()).map(NGSMeasurementProperty::name) - .anyMatch(sanitizedValue::equalsIgnoreCase); - } - - String propertyName() { - return name; - } - } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/ParsingResult.java b/user-interface/src/main/java/life/qbic/datamanager/parser/ParsingResult.java index 95281917d..fe1e8992e 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/parser/ParsingResult.java +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/ParsingResult.java @@ -1,9 +1,7 @@ package life.qbic.datamanager.parser; -import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.stream.Stream; /** * Parsing Result @@ -39,7 +37,7 @@ * * *

- * So the resulting stored positions of every key in a row can be accessed via {@link #keys()} and would look like: + * So the resulting stored positions of every key in a row can be accessed via {@link #columnMap()} and would look like: * *

    *
  • A - 0
  • @@ -57,35 +55,34 @@ * * @since 1.4.0 */ -public record ParsingResult(Map keys, List rows) { +public record ParsingResult(Map columnMap, List rows) { - public ParsingResult(Map keys, List rows) { - this.keys = Map.copyOf(keys); + public ParsingResult(Map columnMap, List rows) { + this.columnMap = Map.copyOf(columnMap); this.rows = List.copyOf(rows); } - public Stream rowsStream() { - return rows.stream(); - } - - public Iterator iterator() { - return rows.iterator(); - } - - public List getRow(int rowIndex) { + public Row getRow(int rowIndex) { if (rowIndex < 0 || rowIndex >= rows.size()) { throw new IndexOutOfBoundsException( "Row index out of bounds: %s but size is %s".formatted(rowIndex, rows.size())); } - return rows.get(rowIndex).values; + return rows.get(rowIndex); } - public record Row(List values) { + public String getValueOrDefault(int rowIndex, String columnHeader, String defaultValue) { + var key = Sanitizer.headerEncoder(columnHeader); + if (!columnMap().containsKey(key)) { + return defaultValue; + } + Row row = getRow(rowIndex); + return row.values().get(columnMap().get(key)); + } + public record Row(List values) { public Row(List values) { this.values = List.copyOf(values); } - } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/NGSMeasurementEditColumn.java b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/NGSMeasurementEditColumn.java new file mode 100644 index 000000000..5fef50044 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/NGSMeasurementEditColumn.java @@ -0,0 +1,74 @@ +package life.qbic.datamanager.parser.measurement; + +import java.util.Arrays; + +/** + * NGS Measurement Columns + * + *

    Enumeration of the columns shown in the file used for NGS measurement edit + * in the context of measurement file based upload. Provides the name of the header column, the + * column index and if the column should be set to readOnly in the generated sheet + *

    + */ +public enum NGSMeasurementEditColumn { + + MEASUREMENT_ID("Measurement ID", 0, true, true), + SAMPLE_ID("QBiC Sample Id", 1, true, true), + SAMPLE_NAME("Sample Name", 2, true, false), + POOL_GROUP("Sample Pool Group", 3, true, false), + ORGANISATION_ID("Organisation ID", 4, false, true), + ORGANISATION_NAME("Organisation Name", 5, true, false), + FACILITY("Facility", 6, false, true), + INSTRUMENT("Instrument", 7, false, true), + INSTRUMENT_NAME("Instrument Name", 8, true, false), + SEQUENCING_READ_TYPE("Sequencing Read Type", 9, false, true), + LIBRARY_KIT("Library Kit", 10, false, false), + FLOW_CELL("Flow Cell", 11, false, false), + SEQUENCING_RUN_PROTOCOL("Sequencing Run Protocol", 12, false, false), + INDEX_I7("Index i7", 13, false, false), + INDEX_I5("Index i5", 14, false, false), + COMMENT("Comment", 15, false, false), + ; + + private final String headerName; + private final int columnIndex; + private final boolean readOnly; + private final boolean mandatory; + + static int maxColumnIndex() { + return Arrays.stream(values()) + .mapToInt(NGSMeasurementEditColumn::columnIndex) + .max().orElse(0); + } + + /** + * @param headerName the name in the header + * @param columnIndex the index of the column this property is in + * @param readOnly is the property read only + * @param mandatory + */ + NGSMeasurementEditColumn(String headerName, int columnIndex, boolean readOnly, + boolean mandatory) { + this.headerName = headerName; + this.columnIndex = columnIndex; + this.readOnly = readOnly; + this.mandatory = mandatory; + } + + public String headerName() { + return headerName; + } + + public int columnIndex() { + return columnIndex; + } + + public boolean isReadOnly() { + return readOnly; + } + + public boolean isMandatory() { + return mandatory; + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/NGSMeasurementRegisterColumn.java b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/NGSMeasurementRegisterColumn.java new file mode 100644 index 000000000..283e91f3a --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/NGSMeasurementRegisterColumn.java @@ -0,0 +1,70 @@ +package life.qbic.datamanager.parser.measurement; + +import java.util.Arrays; + +/** + * NGS Measurement Columns + * + *

    Enumeration of the columns shown in the file used for NGS measurement registration + * in the context of measurement file based upload. Provides the name of the header column, the + * column index and if the column should be set to readOnly in the generated sheet + *

    + */ +public enum NGSMeasurementRegisterColumn { + + SAMPLE_ID("QBiC Sample Id", 0, false, true), + SAMPLE_NAME("Sample Name", 1, false, false), + POOL_GROUP("Sample Pool Group", 2, false, false), + ORGANISATION_ID("Organisation ID", 3, false, true), + FACILITY("Facility", 4, false, true), + INSTRUMENT("Instrument", 5, false, true), + SEQUENCING_READ_TYPE("Sequencing Read Type", 6, false, true), + LIBRARY_KIT("Library Kit", 7, false, false), + FLOW_CELL("Flow Cell", 8, false, false), + SEQUENCING_RUN_PROTOCOL("Sequencing Run Protocol", 11, false, false), + INDEX_I7("Index i7", 9, false, false), + INDEX_I5("Index i5", 10, false, false), + COMMENT("Comment", 11, false, false), + ; + + private final String headerName; + private final int columnIndex; + private final boolean readOnly; + private final boolean mandatory; + + static int maxColumnIndex() { + return Arrays.stream(values()) + .mapToInt(NGSMeasurementRegisterColumn::columnIndex) + .max().orElse(0); + } + + /** + * @param headerName the name in the header + * @param columnIndex the index of the column this property is in + * @param readOnly is the property read only + * @param mandatory + */ + NGSMeasurementRegisterColumn(String headerName, int columnIndex, boolean readOnly, + boolean mandatory) { + this.headerName = headerName; + this.columnIndex = columnIndex; + this.readOnly = readOnly; + this.mandatory = mandatory; + } + + public String headerName() { + return headerName; + } + + public int columnIndex() { + return columnIndex; + } + + public boolean readOnly() { + return readOnly; + } + + public boolean isMandatory() { + return mandatory; + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/ProteomicsMeasurementEditColumn.java b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/ProteomicsMeasurementEditColumn.java new file mode 100644 index 000000000..db45d76b8 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/ProteomicsMeasurementEditColumn.java @@ -0,0 +1,63 @@ +package life.qbic.datamanager.parser.measurement; + +/** + * NGS Measurement Columns + * + *

    Enumeration of the columns shown in the file used for NGS measurement registration and edit + * in the context of measurement file based upload. Provides the name of the header column, the + * column index and if the column should be set to readOnly in the generated sheet + *

    + */ +public enum ProteomicsMeasurementEditColumn { + + MEASUREMENT_ID("Measurement ID", 0, true, true), + SAMPLE_ID("QBiC Sample Id", 1, true, true), + SAMPLE_NAME( + "Sample Name", 2, true, false), + POOL_GROUP("Sample Pool Group", 3, true, false), + TECHNICAL_REPLICATE_NAME("Technical Replicate", 4, false, false), + ORGANISATION_ID("Organisation ID", 5, false, true), + ORGANISATION_NAME("Organisation Name", 6, true, false), + FACILITY("Facility", 7, false, true), + MS_DEVICE("MS Device", 8, false, true), + MS_DEVICE_NAME("MS Device Name", 9, true, true), + CYCLE_FRACTION_NAME("Cycle/Fraction Name", 10, false, false), + DIGESTION_METHOD("Digestion Method", 11, false, true), + DIGESTION_ENZYME("Digestion Enzyme", 12, false, true), + ENRICHMENT_METHOD("Enrichment Method", 13, false, false), + INJECTION_VOLUME("Injection Volume (µL)", 14, false, false), + LC_COLUMN("LC Column", 15, false, true), + LCMS_METHOD("LCMS Method", 16, false, false), + LABELING_TYPE("Labeling Type", 17, false, false), + LABEL("Label", 18, false, false), + COMMENT("Comment", 19, false, false), + ; + private final String headerName; + private final int columnIndex; + private final boolean readOnly; + private final boolean mandatory; + + ProteomicsMeasurementEditColumn(String headerName, int columnIndex, boolean readOnly, + boolean mandatory) { + this.headerName = headerName; + this.columnIndex = columnIndex; + this.readOnly = readOnly; + this.mandatory = mandatory; + } + + public String headerName() { + return headerName; + } + + public int columnIndex() { + return columnIndex; + } + + public boolean isReadOnly() { + return readOnly; + } + + public boolean isMandatory() { + return mandatory; + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/ProteomicsMeasurementRegisterColumn.java b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/ProteomicsMeasurementRegisterColumn.java new file mode 100644 index 000000000..12fdcaadb --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/measurement/ProteomicsMeasurementRegisterColumn.java @@ -0,0 +1,60 @@ +package life.qbic.datamanager.parser.measurement; + +/** + * TODO! + * short description + * + *

    detailed description

    + * + * @since + */ +public enum ProteomicsMeasurementRegisterColumn { + + SAMPLE_ID("QBiC Sample Id", 0, true, true), + SAMPLE_NAME( + "Sample Name", 1, true, false), + POOL_GROUP("Sample Pool Group", 2, true, false), + TECHNICAL_REPLICATE_NAME("Technical Replicate", 3, false, false), + CYCLE_FRACTION_NAME("Cycle/Fraction Name", 4, false, false), + ORGANISATION_ID("Organisation ID", 5, false, true), + FACILITY("Facility", 6, false, true), + LC_COLUMN("LC Column", 7, false, true), + MS_DEVICE("MS Device", 8, false, true), + LCMS_METHOD("LCMS Method", 9, false, false), + DIGESTION_METHOD("Digestion Method", 10, false, true), + DIGESTION_ENZYME("Digestion Enzyme", 11, false, true), + ENRICHMENT_METHOD("Enrichment Method", 12, false, false), + LABELING_TYPE("Labeling Type", 13, false, false), + LABEL("Label", 14, false, false), + INJECTION_VOLUME("Injection Volume (µL)", 15, false, false), + COMMENT("Comment", 16, false, false), + ; + private final String headerName; + private final int columnIndex; + private final boolean readOnly; + private final boolean mandatory; + + ProteomicsMeasurementRegisterColumn(String headerName, int columnIndex, boolean readOnly, + boolean mandatory) { + this.headerName = headerName; + this.columnIndex = columnIndex; + this.readOnly = readOnly; + this.mandatory = mandatory; + } + + public String headerName() { + return headerName; + } + + public int columnIndex() { + return columnIndex; + } + + public boolean isReadOnly() { + return readOnly; + } + + public boolean isMandatory() { + return mandatory; + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/sample/EditColumn.java b/user-interface/src/main/java/life/qbic/datamanager/parser/sample/EditColumn.java new file mode 100644 index 000000000..a562e8084 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/sample/EditColumn.java @@ -0,0 +1,64 @@ +package life.qbic.datamanager.parser.sample; + +import java.util.Arrays; + +/** + * Sample Edit Columns + * + *

    Enumeration of the columns shown in the file used for sample edit + * in the context of sample batch file based upload. Provides the name of the header column, the + * column index and if the column should be set to readOnly in the generated sheet + *

    + */ +public enum EditColumn { + SAMPLE_ID("QBiC Sample Id", 0, true, true), + SAMPLE_NAME("Sample Name", 1, false, true), + ANALYSIS("Analysis to be performed", 2, false, true), + BIOLOGICAL_REPLICATE("Biological Replicate", 3, false, false), + CONDITION("Condition", 4, false, true), + SPECIES("Species", 5, false, true), + ANALYTE("Analyte", 6, false, true), + SPECIMEN("Specimen", 7, false, true), + COMMENT("Comment", 8, false, false); + + private final String headerName; + private final int columnIndex; + private final boolean readOnly; + private final boolean mandatory; + + public static int maxColumnIndex() { + return Arrays.stream(values()) + .mapToInt(EditColumn::columnIndex) + .max().orElse(0); + } + + /** + * @param headerName the name in the header + * @param columnIndex the index of the column this property is in + * @param readOnly is the property read only + * @param mandatory + */ + EditColumn(String headerName, int columnIndex, boolean readOnly, boolean mandatory) { + this.headerName = headerName; + this.columnIndex = columnIndex; + this.readOnly = readOnly; + this.mandatory = mandatory; + } + + public String headerName() { + return headerName; + } + + public int columnIndex() { + return columnIndex; + } + + public boolean isReadOnly() { + return readOnly; + } + + public boolean isMandatory() { + return mandatory; + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/sample/RegisterColumn.java b/user-interface/src/main/java/life/qbic/datamanager/parser/sample/RegisterColumn.java new file mode 100644 index 000000000..ec7c400d2 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/sample/RegisterColumn.java @@ -0,0 +1,63 @@ +package life.qbic.datamanager.parser.sample; + +import java.util.Arrays; + +/** + * Sample Register Columns + * + *

    Enumeration of the columns shown in the file used for sample registration + * in the context of sample batch file based upload. Provides the name of the header column, the + * column index and if the column should be set to readOnly in the generated sheet + *

    + */ +public enum RegisterColumn { + + SAMPLE_NAME("Sample Name", 0, false, true), + ANALYSIS("Analysis to be performed", 1, false, true), + BIOLOGICAL_REPLICATE("Biological Replicate", 2, false, false), + CONDITION("Condition", 3, false, true), + SPECIES("Species", 4, false, true), + ANALYTE("Analyte", 5, false, true), + SPECIMEN("Specimen", 6, false, true), + COMMENT("Comment", 7, false, false); + + private final String headerName; + private final int columnIndex; + private final boolean readOnly; + private final boolean mandatory; + + public static int maxColumnIndex() { + return Arrays.stream(values()) + .mapToInt(RegisterColumn::columnIndex) + .max().orElse(0); + } + + /** + * @param headerName the name in the header + * @param columnIndex the index of the column this property is in + * @param readOnly is the property read only + * @param mandatory + */ + RegisterColumn(String headerName, int columnIndex, boolean readOnly, boolean mandatory) { + this.headerName = headerName; + this.columnIndex = columnIndex; + this.readOnly = readOnly; + this.mandatory = mandatory; + } + + public String headerName() { + return headerName; + } + + public int columnIndex() { + return columnIndex; + } + + public boolean isReadOnly() { + return readOnly; + } + + public boolean isMandatory() { + return mandatory; + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/sample/SampleInformationExtractor.java b/user-interface/src/main/java/life/qbic/datamanager/parser/sample/SampleInformationExtractor.java new file mode 100644 index 000000000..104230d7c --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/sample/SampleInformationExtractor.java @@ -0,0 +1,132 @@ +package life.qbic.datamanager.parser.sample; + +import java.util.ArrayList; +import java.util.List; +import life.qbic.datamanager.parser.ParsingResult; + +/** + * Extracts sample information from a parsing result. + *

    + * This class does not perform any validation and missing entries will be provided with empty + * strings. + * + * @since 1.5.0 + */ +public class SampleInformationExtractor { + + /** + * Extract information for new samples from a parsing result. + * + * @param parsingResult the result of parsing user provided information. + * @return a record of extracted sample information. Missing entries are provided as empty + * strings. + */ + public List extractInformationForNewSamples( + ParsingResult parsingResult) { + var result = new ArrayList(); + for (int i = 0; i < parsingResult.rows().size(); i++) { + var sampleName = parsingResult.getValueOrDefault(i, RegisterColumn.SAMPLE_NAME.headerName(), + ""); + var analysisMethod = parsingResult.getValueOrDefault(i, RegisterColumn.ANALYSIS.headerName(), + ""); + var biologicalReplicate = parsingResult.getValueOrDefault(i, + RegisterColumn.BIOLOGICAL_REPLICATE.headerName(), ""); + var condition = parsingResult.getValueOrDefault(i, RegisterColumn.CONDITION.headerName(), ""); + var species = parsingResult.getValueOrDefault(i, RegisterColumn.SPECIES.headerName(), ""); + var analyte = parsingResult.getValueOrDefault(i, RegisterColumn.ANALYTE.headerName(), ""); + var specimen = parsingResult.getValueOrDefault(i, RegisterColumn.SPECIMEN.headerName(), ""); + var comment = parsingResult.getValueOrDefault(i, RegisterColumn.COMMENT.headerName(), ""); + result.add(new SampleInformationForNewSample( + sampleName, + analysisMethod, + biologicalReplicate, + condition, + species, + analyte, + specimen, + comment)); + } + return result; + } + + /** + * Extract information for existing samples from a parsing result. + * + * @param parsingResult the result of parsing user provided information. + * @return a record of extracted sample information. Missing entries are provided as empty + * strings. + */ + public List extractInformationForExistingSamples( + ParsingResult parsingResult) { + var result = new ArrayList(); + for (int i = 0; i < parsingResult.rows().size(); i++) { + var sampleCode = parsingResult.getValueOrDefault(i, EditColumn.SAMPLE_ID.headerName(), ""); + var sampleName = parsingResult.getValueOrDefault(i, EditColumn.SAMPLE_NAME.headerName(), ""); + var analysisMethod = parsingResult.getValueOrDefault(i, EditColumn.ANALYSIS.headerName(), + ""); + var biologicalReplicate = parsingResult.getValueOrDefault(i, + RegisterColumn.BIOLOGICAL_REPLICATE.headerName(), ""); + var condition = parsingResult.getValueOrDefault(i, EditColumn.CONDITION.headerName(), ""); + var species = parsingResult.getValueOrDefault(i, EditColumn.SPECIES.headerName(), ""); + var analyte = parsingResult.getValueOrDefault(i, EditColumn.ANALYTE.headerName(), ""); + var specimen = parsingResult.getValueOrDefault(i, EditColumn.SPECIMEN.headerName(), ""); + var comment = parsingResult.getValueOrDefault(i, EditColumn.COMMENT.headerName(), ""); + result.add(new SampleInformationForExistingSample(sampleCode, + sampleName, + analysisMethod, + biologicalReplicate, + condition, + species, + specimen, + analyte, + comment + )); + } + return result; + } + + /** + * Information expected for registering new samples + * + * @param condition + * @param species + * @param specimen + * @param analyte + * @param analysisMethod + */ + public record SampleInformationForNewSample( + String sampleName, + String analysisMethod, + String biologicalReplicate, + String condition, + String species, + String analyte, + String specimen, + String comment + ) { + + } + + /** + * Information expected for editing existing samples + * + * @param sampleCode + * @param condition + * @param species + * @param specimen + * @param analyte + * @param analysisMethod + */ + public record SampleInformationForExistingSample( + String sampleCode, + String sampleName, + String analysisMethod, + String biologicalReplicate, + String condition, + String species, + String specimen, + String analyte, + String comment) { + + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/parser/tsv/TSVParser.java b/user-interface/src/main/java/life/qbic/datamanager/parser/tsv/TSVParser.java index 43e818e6a..8c60d27bd 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/parser/tsv/TSVParser.java +++ b/user-interface/src/main/java/life/qbic/datamanager/parser/tsv/TSVParser.java @@ -24,7 +24,7 @@ * Support for UTF-16 encoding available. *

    * This implementation always considers the first line as the header, and will use its information - * to create the {@link ParsingResult#keys()} in the returned {@link ParsingResult} object + * to create the {@link ParsingResult#columnMap()} in the returned {@link ParsingResult} object * instance. * * @since 1.4.0 diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/Template.java b/user-interface/src/main/java/life/qbic/datamanager/templates/Template.java index fffbd4d1f..972bc918b 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/templates/Template.java +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/Template.java @@ -1,6 +1,6 @@ package life.qbic.datamanager.templates; -import life.qbic.datamanager.views.general.download.DownloadContentProvider; +import life.qbic.datamanager.download.DownloadContentProvider; /** * diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/TemplateDownloadFactory.java b/user-interface/src/main/java/life/qbic/datamanager/templates/TemplateDownloadFactory.java index 859f6fd54..84491cdf6 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/templates/TemplateDownloadFactory.java +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/TemplateDownloadFactory.java @@ -1,6 +1,8 @@ package life.qbic.datamanager.templates; -import life.qbic.datamanager.views.general.download.DownloadContentProvider; +import life.qbic.datamanager.download.DownloadContentProvider; +import life.qbic.datamanager.templates.measurement.NGSMeasurementTemplate; +import life.qbic.datamanager.templates.measurement.ProteomicsMeasurementTemplate; /** * Template Download Factory @@ -15,7 +17,7 @@ public class TemplateDownloadFactory { public static Template provider(TemplateType templateType) { return switch (templateType) { - case MS_MEASUREMENT -> new MSMeasurementTemplate(); + case MS_MEASUREMENT -> new ProteomicsMeasurementTemplate(); case NGS_MEASUREMENT -> new NGSMeasurementTemplate(); }; } diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/TemplateService.java b/user-interface/src/main/java/life/qbic/datamanager/templates/TemplateService.java new file mode 100644 index 000000000..2b3f5ac7d --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/TemplateService.java @@ -0,0 +1,138 @@ +package life.qbic.datamanager.templates; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import life.qbic.datamanager.templates.sample.SampleBatchRegistrationTemplate; +import life.qbic.datamanager.templates.sample.SampleBatchUpdateTemplate; +import life.qbic.projectmanagement.application.experiment.ExperimentInformationService; +import life.qbic.projectmanagement.application.sample.PropertyConversion; +import life.qbic.projectmanagement.application.sample.SampleInformationService; +import life.qbic.projectmanagement.domain.model.batch.BatchId; +import life.qbic.projectmanagement.domain.model.experiment.Experiment; +import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; +import life.qbic.projectmanagement.domain.model.experiment.ExperimentalGroup; +import life.qbic.projectmanagement.domain.model.sample.AnalysisMethod; +import life.qbic.projectmanagement.domain.model.sample.Sample; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +/** + * Template Service + *

    + * Service that enables access to various template generation methods to support tasks such as + * sample batch registration and sample batch update. + * + * @since 1.5.0 + */ +@Service +public class TemplateService { + + private final ExperimentInformationService experimentInfoService; + + private final SampleInformationService sampleInformationService; + + @Autowired + public TemplateService(ExperimentInformationService experimentInfoService, + SampleInformationService sampleInformationService) { + this.experimentInfoService = Objects.requireNonNull(experimentInfoService); + this.sampleInformationService = Objects.requireNonNull(sampleInformationService); + } + + /** + * Creates a {@link XSSFWorkbook} that contains a template + * {@link org.apache.poi.xssf.usermodel.XSSFSheet} that can be used to register one or more sample + * batches for an experiment. + *

    + * The workbook contains two sheets. The first one is for the user input and contains cell with + * data-validation, e.g. a list of available conditions in the experiment. + *

    + * In total, the template provides data validation for the properties: + * + *

      + *
    • Species
    • + *
    • Specimen
    • + *
    • Analyte
    • + *
    • Condition
    • + *
    • Analysis to perform
    • + *
    + * + * @param projectId the project id of the project that contains the experiment for which the + * template shall be generated + * @param experimentId the experiment id of the experiment to create the template for + * @return a pre-configured template workbook + * @throws NoSuchExperimentException if no experiment with the provided id can be found. + * @since 1.5.0 + */ + @PreAuthorize( + "hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'READ') ") + public XSSFWorkbook sampleBatchRegistrationXLSXTemplate(String projectId, String experimentId) + throws NoSuchExperimentException { + var experiment = experimentInfoService.find(projectId, ExperimentId.parse(experimentId)) + .orElseThrow( + NoSuchExperimentException::new); + return createWorkbookFromExperiment(experiment); + } + + @PreAuthorize( + "hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project', 'READ') ") + public XSSFWorkbook sampleBatchUpdateXLSXTemplate(BatchId batchId, String projectId, + String experimentId) + throws NoSuchExperimentException, SampleSearchException { + var experiment = experimentInfoService.find(projectId, ExperimentId.parse(experimentId)) + .orElseThrow( + NoSuchExperimentException::new); + var samples = sampleInformationService.retrieveSamplesForExperiment( + ExperimentId.parse(experimentId)); + samples.onError(responseCode -> { + throw new SampleSearchException(); + }); + var samplesInBatch = samples.getValue().stream() + .filter(sample -> sample.assignedBatch().equals(batchId)) + .toList(); + return createPrefilledWorkbookFromExperiment(experiment, samplesInBatch); + } + + private XSSFWorkbook createPrefilledWorkbookFromExperiment(Experiment experiment, + List samples) { + var conditions = experiment.getExperimentalGroups().stream().map(ExperimentalGroup::condition) + .map( + PropertyConversion::toString).toList(); + var experimentalGroups = experiment.getExperimentalGroups(); + var species = experiment.getSpecies().stream().map(PropertyConversion::toString).toList(); + var specimen = experiment.getSpecimens().stream().map(PropertyConversion::toString).toList(); + var analytes = experiment.getAnalytes().stream().map(PropertyConversion::toString).toList(); + var analysisMethods = Arrays.stream(AnalysisMethod.values()).map(AnalysisMethod::abbreviation) + .toList(); + return SampleBatchUpdateTemplate.createUpdateTemplate(samples, + conditions, species, + specimen, analytes, + analysisMethods, experimentalGroups); + } + + private XSSFWorkbook createWorkbookFromExperiment(Experiment experiment) { + var conditions = experiment.getExperimentalGroups().stream().map(ExperimentalGroup::condition) + .map( + PropertyConversion::toString).toList(); + var species = experiment.getSpecies().stream().map(PropertyConversion::toString).toList(); + var specimen = experiment.getSpecimens().stream().map(PropertyConversion::toString).toList(); + var analytes = experiment.getAnalytes().stream().map(PropertyConversion::toString).toList(); + var analysisMethods = Arrays.stream(AnalysisMethod.values()).map(AnalysisMethod::abbreviation) + .toList(); + return SampleBatchRegistrationTemplate.createRegistrationTemplate( + conditions, species, + specimen, analytes, + analysisMethods); + } + + public static class NoSuchExperimentException extends RuntimeException { + + } + + public static class SampleSearchException extends RuntimeException { + + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/spreadsheet/XLSXTemplateHelper.java b/user-interface/src/main/java/life/qbic/datamanager/templates/XLSXTemplateHelper.java similarity index 63% rename from user-interface/src/main/java/life/qbic/datamanager/spreadsheet/XLSXTemplateHelper.java rename to user-interface/src/main/java/life/qbic/datamanager/templates/XLSXTemplateHelper.java index 1ddec66fe..b57e1eee2 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/spreadsheet/XLSXTemplateHelper.java +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/XLSXTemplateHelper.java @@ -1,16 +1,21 @@ -package life.qbic.datamanager.spreadsheet; +package life.qbic.datamanager.templates; import static java.util.Objects.isNull; import static java.util.Objects.nonNull; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.Random; import java.util.function.Predicate; import java.util.stream.Collectors; import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.DataValidation; import org.apache.poi.ss.usermodel.DataValidationConstraint; import org.apache.poi.ss.usermodel.DataValidationHelper; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; import org.apache.poi.ss.usermodel.Name; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -18,6 +23,9 @@ import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.util.CellRangeAddressList; import org.apache.poi.ss.util.CellReference; +import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap; +import org.apache.poi.xssf.usermodel.XSSFColor; +import org.apache.poi.xssf.usermodel.XSSFFont; /** * Helps to create excel template sheets. @@ -30,6 +38,9 @@ public class XLSXTemplateHelper { private static final Random RANDOM = new Random(); + private static final byte[] DARK_GREY = {(byte) 119, (byte) 119, (byte) 119}; + private static final byte[] LIGHT_GREY = {(byte) 220, (byte) 220, (byte) 220}; + private static final int COLUMN_MAX_WIDTH = 255; protected XLSXTemplateHelper() { //hide constructor as static methods only are used @@ -89,6 +100,90 @@ public static String getCellValueAsString(Cell cell) { }; } + static Optional findBoldCellStyle(Sheet sheet) { + for (int i = 0; i < sheet.getWorkbook().getNumCellStyles(); i++) { + CellStyle cellStyleAt = sheet.getWorkbook().getCellStyleAt(i); + int fontIndex = cellStyleAt.getFontIndex(); + Font fontAt = sheet.getWorkbook().getFontAt(fontIndex); + if (fontAt.getBold()) { + return Optional.of(cellStyleAt); + } + } + return Optional.empty(); + } + + /** + * Sets a range of columns via {@link Sheet#autoSizeColumn(int)} to set the width automatically to + * the maximum cell value length observed in a column. + * + * @param sheet the sheet for which the columns shall be adjusted + * @param columnIndexStart the first column index to start the formatting + * @param columnIndexEnd the last column (inclusive) to have to formatting included + * @since 1.5.0 + */ + public static void setColumnAutoWidth(Sheet sheet, int columnIndexStart, + int columnIndexEnd) { + for (int currentColumn = columnIndexStart; currentColumn <= columnIndexEnd; + currentColumn++) { + sheet.autoSizeColumn(currentColumn); + } + } + + /** + * Sets the width of a column explicitly. The width is expected to be the number in characters to + * show. + *

    + * Disclaimer: The current maximal value for the width is 255, since we inherit this restraint + * from the underlying framework. See {@link Sheet#setColumnWidth(int, int)} for more. + * + * @param sheet the sheet for which the column shall be adjusted + * @param columnIndex the index of the column to adjust + * @param widthInCharacters the designated width of the column in number of characters + * @throws IllegalArgumentException if the number of characters > 255 + * @since 1.5.0 + */ + public static void setColumnWidth(Sheet sheet, int columnIndex, int widthInCharacters) + throws IllegalArgumentException { + Objects.requireNonNull(sheet); + if (widthInCharacters > COLUMN_MAX_WIDTH) { + throw new IllegalArgumentException( + "Column width must be less than %s characters. Provided: %s".formatted(COLUMN_MAX_WIDTH, widthInCharacters)); + } + sheet.setColumnWidth(columnIndex, widthInCharacters * 256); + } + + + public static CellStyle createBoldCellStyle(Workbook workbook) { + CellStyle boldStyle = workbook.createCellStyle(); + Font fontBold = workbook.createFont(); + fontBold.setBold(true); + boldStyle.setFont(fontBold); + + return boldStyle; + } + + public static CellStyle createReadOnlyCellStyle(Workbook workbook) { + CellStyle readOnlyStyle = workbook.createCellStyle(); + readOnlyStyle.setFillForegroundColor(new XSSFColor(LIGHT_GREY, new DefaultIndexedColorMap())); + readOnlyStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + XSSFFont font = (XSSFFont) workbook.createFont(); + font.setColor(new XSSFColor(DARK_GREY, new DefaultIndexedColorMap())); + readOnlyStyle.setFont(font); + return readOnlyStyle; + } + + public static CellStyle createReadOnlyHeaderCellStyle(Workbook workbook) { + CellStyle readOnlyHeaderStyle = workbook.createCellStyle(); + readOnlyHeaderStyle.setFillForegroundColor( + new XSSFColor(LIGHT_GREY, new DefaultIndexedColorMap())); + readOnlyHeaderStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + XSSFFont fontHeader = (XSSFFont) workbook.createFont(); + fontHeader.setBold(true); + fontHeader.setColor(new XSSFColor(DARK_GREY, new DefaultIndexedColorMap())); + readOnlyHeaderStyle.setFont(fontHeader); + return readOnlyHeaderStyle; + } + /** * Adds values to the sheet and returns the named area where they were added. * @@ -103,13 +198,16 @@ public static Name createOptionArea(Sheet sheet, String propertyName, List options) { Row headerRow = getOrCreateRow(sheet, 0); var columnNumber = Math.max(1, - headerRow.getLastCellNum()); // we want to obtain 1 for the first to come if there are none and not -1 -.- + headerRow.getLastCellNum() + 1); // the column to use for the property. Starts with 1 var columnIndex = columnNumber - 1; // create header cell Cell headerRowCell = headerRow.createCell(columnIndex); headerRowCell.setCellValue(propertyName); + //if a bold cell style exists, use it + findBoldCellStyle(sheet).ifPresent(headerRowCell::setCellStyle); + var startIndex = 1; // ignore the header at 0 var rowIndex = startIndex; for (String option : options) { diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/NGSMeasurementEditTemplate.java b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/NGSMeasurementEditTemplate.java new file mode 100644 index 000000000..b75de213e --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/NGSMeasurementEditTemplate.java @@ -0,0 +1,179 @@ +package life.qbic.datamanager.templates.measurement; + +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createBoldCellStyle; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createOptionArea; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createReadOnlyCellStyle; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createReadOnlyHeaderCellStyle; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.getOrCreateCell; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.getOrCreateRow; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.hideSheet; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.lockSheet; +import static life.qbic.logging.service.LoggerFactory.logger; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import life.qbic.application.commons.ApplicationException; +import life.qbic.application.commons.ApplicationException.ErrorCode; +import life.qbic.datamanager.download.DownloadContentProvider; +import life.qbic.datamanager.parser.measurement.NGSMeasurementEditColumn; +import life.qbic.datamanager.templates.XLSXTemplateHelper; +import life.qbic.datamanager.views.projects.project.measurements.NGSMeasurementEntry; +import life.qbic.logging.api.Logger; +import life.qbic.projectmanagement.application.measurement.NGSMeasurementMetadata; +import life.qbic.projectmanagement.domain.model.measurement.NGSMeasurement; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Name; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +/** + * NGS Measurement Content Provider + *

    + * Implementation of the {@link DownloadContentProvider} providing the content and file name for any + * files created from {@link NGSMeasurement} and {@link NGSMeasurementMetadata} + *

    + */ +public class NGSMeasurementEditTemplate implements DownloadContentProvider { + + private static final String FILE_NAME_SUFFIX = "ngs_measurements.xlsx"; + private static final Logger log = logger(NGSMeasurementEditTemplate.class); + private final List measurements = new LinkedList<>(); + private static final String DEFAULT_FILE_NAME_PREFIX = "QBiC"; + private String fileNamePrefix = DEFAULT_FILE_NAME_PREFIX; + private static final int DEFAULT_GENERATED_ROW_COUNT = 200; + + private enum SequencingReadType { + SINGLE_END("single-end"), + PAIRED_END("paired-end"); + private final String presentationString; + + SequencingReadType(String presentationString) { + this.presentationString = presentationString; + } + + static List getOptions() { + return Arrays.stream(values()).map(it -> it.presentationString).toList(); + } + } + + private static void setAutoWidth(Sheet sheet) { + for (int col = 0; col <= NGSMeasurementEditColumn.values().length; col++) { + sheet.autoSizeColumn(col); + } + } + + private static void writeMeasurementIntoRow(NGSMeasurementEntry ngsMeasurementEntry, + Row entryRow, CellStyle readOnlyCellStyle) { + + for (NGSMeasurementEditColumn measurementColumn : NGSMeasurementEditColumn.values()) { + var value = switch (measurementColumn) { + case MEASUREMENT_ID -> ngsMeasurementEntry.measurementCode(); + case SAMPLE_ID -> ngsMeasurementEntry.sampleInformation().sampleId(); + case SAMPLE_NAME -> ngsMeasurementEntry.sampleInformation().sampleName(); + case POOL_GROUP -> ngsMeasurementEntry.samplePoolGroup(); + case ORGANISATION_ID -> ngsMeasurementEntry.organisationId(); + case ORGANISATION_NAME -> ngsMeasurementEntry.organisationName(); + case FACILITY -> ngsMeasurementEntry.facility(); + case INSTRUMENT -> ngsMeasurementEntry.instrumentCURI(); + case INSTRUMENT_NAME -> ngsMeasurementEntry.instrumentName(); + case SEQUENCING_READ_TYPE -> ngsMeasurementEntry.readType(); + case LIBRARY_KIT -> ngsMeasurementEntry.libraryKit(); + case FLOW_CELL -> ngsMeasurementEntry.flowCell(); + case SEQUENCING_RUN_PROTOCOL -> ngsMeasurementEntry.runProtocol(); + case INDEX_I7 -> ngsMeasurementEntry.indexI7(); + case INDEX_I5 -> ngsMeasurementEntry.indexI5(); + case COMMENT -> ngsMeasurementEntry.comment(); + }; + var cell = getOrCreateCell(entryRow, measurementColumn.columnIndex()); + cell.setCellValue(value); + if (measurementColumn.isReadOnly()) { + cell.setCellStyle(readOnlyCellStyle); + } + } + + + } + + public void setMeasurements(List measurements, String fileNamePrefix) { + this.measurements.clear(); + this.measurements.addAll(measurements); + this.fileNamePrefix = fileNamePrefix.trim(); + } + + + @Override + public byte[] getContent() { + if (measurements.isEmpty()) { + return new byte[0]; + } + + try (Workbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + CellStyle readOnlyCellStyle = createReadOnlyCellStyle(workbook); + CellStyle readOnlyHeaderStyle = createReadOnlyHeaderCellStyle(workbook); + CellStyle boldStyle = createBoldCellStyle(workbook); + + Sheet sheet = workbook.createSheet("NGS Measurement Metadata"); + + Row header = getOrCreateRow(sheet, 0); + for (NGSMeasurementEditColumn value : NGSMeasurementEditColumn.values()) { + var cell = getOrCreateCell(header, value.columnIndex()); + if (value.isMandatory()) { + cell.setCellValue(value.headerName() + "*"); + } else { + cell.setCellValue(value.headerName()); + } + cell.setCellStyle(boldStyle); + if (value.isReadOnly()) { + cell.setCellStyle(readOnlyHeaderStyle); + } + } + + var startIndex = 1; // start in row number 2 with index 1 as the header row has number 1 index 0 + int rowIndex = startIndex; + for (NGSMeasurementEntry measurement : measurements) { + Row row = getOrCreateRow(sheet, rowIndex); + writeMeasurementIntoRow(measurement, row, readOnlyCellStyle); + rowIndex++; + } + + var generatedRowCount = rowIndex - startIndex; + assert generatedRowCount == measurements.size() : "all measurements have a corresponding row"; + + // make sure to create the visible sheet first + Sheet hiddenSheet = workbook.createSheet("hidden"); + Name sequencingReadTypeArea = createOptionArea(hiddenSheet, + "Sequencing read type", SequencingReadType.getOptions()); + + XLSXTemplateHelper.addDataValidation(sheet, + NGSMeasurementEditColumn.SEQUENCING_READ_TYPE.columnIndex(), + startIndex, + NGSMeasurementEditColumn.SEQUENCING_READ_TYPE.columnIndex(), + DEFAULT_GENERATED_ROW_COUNT - 1, + sequencingReadTypeArea); + + setAutoWidth(sheet); + workbook.setActiveSheet(0); + + lockSheet(hiddenSheet); + hideSheet(workbook, hiddenSheet); + + workbook.write(byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + log.error(e.getMessage(), e); + throw new ApplicationException(ErrorCode.GENERAL, null); + } + } + + @Override + public String getFileName() { + return String.join("_", fileNamePrefix, FILE_NAME_SUFFIX); + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/NGSMeasurementTemplate.java b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/NGSMeasurementTemplate.java similarity index 91% rename from user-interface/src/main/java/life/qbic/datamanager/templates/NGSMeasurementTemplate.java rename to user-interface/src/main/java/life/qbic/datamanager/templates/measurement/NGSMeasurementTemplate.java index 2f7abd28b..3e74ab6cf 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/templates/NGSMeasurementTemplate.java +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/NGSMeasurementTemplate.java @@ -1,7 +1,8 @@ -package life.qbic.datamanager.templates; +package life.qbic.datamanager.templates.measurement; import java.io.IOException; import java.util.Objects; +import life.qbic.datamanager.templates.Template; /** * NGS measurement template diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/download/ProteomicsMeasurementContentProvider.java b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/ProteomicsMeasurementEditTemplate.java similarity index 54% rename from user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/download/ProteomicsMeasurementContentProvider.java rename to user-interface/src/main/java/life/qbic/datamanager/templates/measurement/ProteomicsMeasurementEditTemplate.java index 05de7d2b4..7fce85779 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/download/ProteomicsMeasurementContentProvider.java +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/ProteomicsMeasurementEditTemplate.java @@ -1,11 +1,13 @@ -package life.qbic.datamanager.views.projects.project.measurements.download; - -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.addDataValidation; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.createOptionArea; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.getOrCreateCell; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.getOrCreateRow; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.hideSheet; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.lockSheet; +package life.qbic.datamanager.templates.measurement; + +import static life.qbic.datamanager.templates.XLSXTemplateHelper.addDataValidation; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createBoldCellStyle; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createOptionArea; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createReadOnlyCellStyle; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.getOrCreateCell; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.getOrCreateRow; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.hideSheet; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.lockSheet; import static life.qbic.logging.service.LoggerFactory.logger; import java.io.ByteArrayOutputStream; @@ -14,22 +16,19 @@ import java.util.List; import life.qbic.application.commons.ApplicationException; import life.qbic.application.commons.ApplicationException.ErrorCode; -import life.qbic.datamanager.views.general.download.DownloadContentProvider; +import life.qbic.datamanager.download.DownloadContentProvider; +import life.qbic.datamanager.parser.measurement.ProteomicsMeasurementEditColumn; +import life.qbic.datamanager.templates.XLSXTemplateHelper; import life.qbic.datamanager.views.projects.project.measurements.ProteomicsMeasurementEntry; import life.qbic.logging.api.Logger; import life.qbic.projectmanagement.application.measurement.ProteomicsMeasurementMetadata; import life.qbic.projectmanagement.application.measurement.validation.MeasurementProteomicsValidator.DigestionMethod; import life.qbic.projectmanagement.domain.model.measurement.ProteomicsMeasurement; import org.apache.poi.ss.usermodel.CellStyle; -import org.apache.poi.ss.usermodel.FillPatternType; -import org.apache.poi.ss.usermodel.Font; import org.apache.poi.ss.usermodel.Name; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap; -import org.apache.poi.xssf.usermodel.XSSFColor; -import org.apache.poi.xssf.usermodel.XSSFFont; import org.apache.poi.xssf.usermodel.XSSFWorkbook; /** Proteomics Measurement Content Provider @@ -39,12 +38,10 @@ * and {@link ProteomicsMeasurementMetadata} *

    */ -public class ProteomicsMeasurementContentProvider implements DownloadContentProvider { +public class ProteomicsMeasurementEditTemplate implements DownloadContentProvider { private static final String FILE_NAME_SUFFIX = "proteomics_measurements.xlsx"; - private static final Logger log = logger(ProteomicsMeasurementContentProvider.class); - private static final byte[] DARK_GREY = {119, 119, 119}; - private static final byte[] LIGHT_GREY = {(byte) 220, (byte) 220, (byte) 220}; + private static final Logger log = logger(ProteomicsMeasurementEditTemplate.class); private final List measurements = new LinkedList<>(); private static final String DEFAULT_FILE_NAME_PREFIX = "QBiC"; private String fileNamePrefix = DEFAULT_FILE_NAME_PREFIX; @@ -59,7 +56,7 @@ private static void setAutoWidth(Sheet sheet) { private static void createMeasurementEntry(ProteomicsMeasurementEntry pxpEntry, Row entryRow, CellStyle readOnlyStyle) { - for (ProteomicsMeasurementColumns measurementColumn : ProteomicsMeasurementColumns.values()) { + for (ProteomicsMeasurementEditColumn measurementColumn : ProteomicsMeasurementEditColumn.values()) { var value = switch (measurementColumn) { case MEASUREMENT_ID -> pxpEntry.measurementCode(); case SAMPLE_ID -> pxpEntry.sampleInformation().sampleId(); @@ -84,7 +81,8 @@ private static void createMeasurementEntry(ProteomicsMeasurementEntry pxpEntry, }; var cell = getOrCreateCell(entryRow, measurementColumn.columnIndex()); cell.setCellValue(value); - if (measurementColumn.readOnly()) { + cell.setCellValue(value); + if (measurementColumn.isReadOnly()) { cell.setCellStyle(readOnlyStyle); } } @@ -102,37 +100,23 @@ public byte[] getContent() { return new byte[0]; } - ByteArrayOutputStream byteArrayOutputStream; - - try (Workbook workbook = new XSSFWorkbook()) { - - CellStyle readOnlyHeaderStyle = workbook.createCellStyle(); - readOnlyHeaderStyle.setFillForegroundColor( - new XSSFColor(LIGHT_GREY, new DefaultIndexedColorMap())); - readOnlyHeaderStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); - XSSFFont fontHeader = (XSSFFont) workbook.createFont(); - fontHeader.setBold(true); - fontHeader.setColor(new XSSFColor(DARK_GREY, new DefaultIndexedColorMap())); - readOnlyHeaderStyle.setFont(fontHeader); - - CellStyle boldStyle = workbook.createCellStyle(); - Font fontBold = workbook.createFont(); - fontBold.setBold(true); - boldStyle.setFont(fontBold); + try (Workbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();) { - CellStyle readOnlyStyle = workbook.createCellStyle(); - readOnlyStyle.setFillForegroundColor(new XSSFColor(LIGHT_GREY, new DefaultIndexedColorMap())); - readOnlyStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); - XSSFFont font = (XSSFFont) workbook.createFont(); - font.setColor(new XSSFColor(DARK_GREY, new DefaultIndexedColorMap())); - readOnlyStyle.setFont(font); + CellStyle readOnlyHeaderStyle = XLSXTemplateHelper.createReadOnlyHeaderCellStyle(workbook); + CellStyle boldStyle = createBoldCellStyle(workbook); + CellStyle readOnlyStyle = createReadOnlyCellStyle(workbook); Sheet sheet = workbook.createSheet("Proteomics Measurement Metadata"); Row header = getOrCreateRow(sheet, 0); - for (ProteomicsMeasurementColumns measurementColumn : ProteomicsMeasurementColumns.values()) { + for (ProteomicsMeasurementEditColumn measurementColumn : ProteomicsMeasurementEditColumn.values()) { var cell = getOrCreateCell(header, measurementColumn.columnIndex()); - cell.setCellValue(measurementColumn.headerName()); - if (measurementColumn.readOnly()) { + if (measurementColumn.isMandatory()) { + cell.setCellValue(measurementColumn.headerName() + "*"); + } else { + cell.setCellValue(measurementColumn.headerName()); + } + if (measurementColumn.isReadOnly()) { cell.setCellStyle(readOnlyHeaderStyle); } else { cell.setCellStyle(boldStyle); @@ -156,8 +140,8 @@ public byte[] getContent() { DigestionMethod.getOptions()); addDataValidation(sheet, - ProteomicsMeasurementColumns.DIGESTION_METHOD.columnIndex(), startIndex, - ProteomicsMeasurementColumns.DIGESTION_METHOD.columnIndex(), + ProteomicsMeasurementEditColumn.DIGESTION_METHOD.columnIndex(), startIndex, + ProteomicsMeasurementEditColumn.DIGESTION_METHOD.columnIndex(), DEFAULT_GENERATED_ROW_COUNT - 1, digestionMethodArea); @@ -167,62 +151,14 @@ public byte[] getContent() { lockSheet(hiddenSheet); hideSheet(workbook, hiddenSheet); - byteArrayOutputStream = new ByteArrayOutputStream(); workbook.write(byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); } catch (IOException e) { log.error(e.getMessage(), e); throw new ApplicationException(ErrorCode.GENERAL, null); } - - return byteArrayOutputStream.toByteArray(); } - enum ProteomicsMeasurementColumns { - - MEASUREMENT_ID("Measurement ID", 0, true), - SAMPLE_ID("QBiC Sample Id", 1, true), - SAMPLE_NAME( - "Sample Name", 2, true), - POOL_GROUP("Sample Pool Group", 3, true), - TECHNICAL_REPLICATE_NAME("Technical Replicate", 4, false), - ORGANISATION_ID("Organisation ID", 5, false), - ORGANISATION_NAME("Organisation Name", 6, true), - FACILITY("Facility", 7, false), - MS_DEVICE("MS Device", 8, false), - MS_DEVICE_NAME("MS Device Name", 9, true), - CYCLE_FRACTION_NAME("Cycle/Fraction Name", 10, false), - DIGESTION_METHOD("Digestion Method", 11, false), - DIGESTION_ENZYME("Digestion Enzyme", 12, false), - ENRICHMENT_METHOD("Enrichment Method", 13, false), - INJECTION_VOLUME("Injection Volume (µL)", 14, false), - LC_COLUMN("LC Column", 15, false), - LCMS_METHOD("LCMS Method", 16, false), - LABELING_TYPE("Labeling Type", 17, false), - LABEL("Label", 18, false), - COMMENT("Comment", 19, false), - ; - private final String headerName; - private final int columnIndex; - private final boolean readOnly; - - ProteomicsMeasurementColumns(String headerName, int columnIndex, boolean readOnly) { - this.headerName = headerName; - this.columnIndex = columnIndex; - this.readOnly = readOnly; - } - - public String headerName() { - return headerName; - } - - public int columnIndex() { - return columnIndex; - } - - public boolean readOnly() { - return readOnly; - } - } @Override public String getFileName() { diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/MSMeasurementTemplate.java b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/ProteomicsMeasurementTemplate.java similarity index 84% rename from user-interface/src/main/java/life/qbic/datamanager/templates/MSMeasurementTemplate.java rename to user-interface/src/main/java/life/qbic/datamanager/templates/measurement/ProteomicsMeasurementTemplate.java index 3c3d3f06f..ca544df95 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/templates/MSMeasurementTemplate.java +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/measurement/ProteomicsMeasurementTemplate.java @@ -1,7 +1,8 @@ -package life.qbic.datamanager.templates; +package life.qbic.datamanager.templates.measurement; import java.io.IOException; import java.util.Objects; +import life.qbic.datamanager.templates.Template; /** * MS measurement template @@ -11,7 +12,7 @@ * * @since 1.0.0 */ -public class MSMeasurementTemplate extends Template { +public class ProteomicsMeasurementTemplate extends Template { private static final String MS_MEASUREMENT_TEMPLATE_PATH = "templates/proteomics_measurement_registration_sheet.xlsx"; @@ -19,7 +20,7 @@ public class MSMeasurementTemplate extends Template { private static final String MS_MEASUREMENT_TEMPLATE_DOMAIN_NAME = "Proteomics Template"; - public MSMeasurementTemplate() { + public ProteomicsMeasurementTemplate() { } @Override diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/sample/SampleBatchRegistrationTemplate.java b/user-interface/src/main/java/life/qbic/datamanager/templates/sample/SampleBatchRegistrationTemplate.java new file mode 100644 index 000000000..2b02f1ce9 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/sample/SampleBatchRegistrationTemplate.java @@ -0,0 +1,146 @@ +package life.qbic.datamanager.templates.sample; + +import static life.qbic.datamanager.templates.XLSXTemplateHelper.*; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.addDataValidation; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createOptionArea; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.getOrCreateRow; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.hideSheet; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.lockSheet; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.setColumnAutoWidth; + +import java.util.Collection; +import java.util.List; +import life.qbic.datamanager.parser.sample.RegisterColumn; +import life.qbic.datamanager.templates.XLSXTemplateHelper; +import org.apache.poi.ss.usermodel.Name; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +/** + * TODO! + * short description + * + *

    detailed description

    + * + * @since + */ +public class SampleBatchRegistrationTemplate { + + public static final int MAX_ROW_INDEX_TO = 2000; + + /** + * Creates a template {@link XSSFWorkbook} for sample batch registration. + *

    + * The client currently can expect that the workbook contains two {@link XSSFSheet}, accessible + * via {@link XSSFWorkbook#getSheetAt(int)}. + *

    + * At position 0, the sheet contains the actual template with column headers for the properties + * expected for registration. If provided, some properties will also contain field validation + * based on enumeration of selection choices. + *

    + * This currently is true for the following properties: + * + *

      + *
    • Species
    • + *
    • Specimen
    • + *
    • Analyte
    • + *
    • Condition
    • + *
    • Analysis to perform
    • + *
    + *

    + * At position 1, the sheet is hidden and protected, containing fixed values for the field validation + * in sheet 0. We don't want users to manually change them. They are given via the experimental + * design. This is a compromise of using technical identifiers vs ease of use for property values. + * + * @param conditions all conditions available in the experiment to select from + * @param species all the species available in the experiment to select from + * @param specimen all the specimen available in the experiment to select from + * @param analytes all the analytes available in the experiment to select from + * @return a pre-configured template workbook + * @since 1.5.0 + */ + public static XSSFWorkbook createRegistrationTemplate(List conditions, + List species, List specimen, List analytes, + List analysisToPerform) { + XSSFWorkbook workbook = new XSSFWorkbook(); + var readOnlyHeaderStyle = createReadOnlyHeaderCellStyle(workbook); + var boldCellStyle = createBoldCellStyle(workbook); + + var sheet = workbook.createSheet("Sample Metadata"); + + Row header = getOrCreateRow(sheet, 0); + for (RegisterColumn column : RegisterColumn.values()) { + var cell = getOrCreateCell(header, column.columnIndex()); + + cell.setCellStyle(boldCellStyle); + if (column.isMandatory()) { + cell.setCellValue(column.headerName() + "*"); + } else { + cell.setCellValue(column.headerName()); + } + if (column.isReadOnly()) { + cell.setCellStyle(readOnlyHeaderStyle); + } + } + var startIndex = 1; //start in the second row with index 1. + + var hiddenSheet = workbook.createSheet("hidden"); + Name analysisToBePerformedOptions = createOptionArea(hiddenSheet, "Analysis to be performed", + analysisToPerform); + Name conditionOptions = createOptionArea(hiddenSheet, "Condition", conditions); + Name analyteOptions = createOptionArea(hiddenSheet, "Analyte", analytes); + Name speciesOptions = createOptionArea(hiddenSheet, "Species", species); + Name specimenOptions = createOptionArea(hiddenSheet, "Specimen", specimen); + + addDataValidation(sheet, + RegisterColumn.ANALYSIS.columnIndex(), + startIndex, + RegisterColumn.ANALYSIS.columnIndex(), + MAX_ROW_INDEX_TO, + analysisToBePerformedOptions); + addDataValidation(sheet, + RegisterColumn.CONDITION.columnIndex(), + startIndex, + RegisterColumn.CONDITION.columnIndex(), + MAX_ROW_INDEX_TO, + conditionOptions); + addDataValidation(sheet, + RegisterColumn.ANALYTE.columnIndex(), + startIndex, + RegisterColumn.ANALYTE.columnIndex(), + MAX_ROW_INDEX_TO, + analyteOptions); + addDataValidation(sheet, + RegisterColumn.SPECIES.columnIndex(), + startIndex, + RegisterColumn.SPECIES.columnIndex(), + MAX_ROW_INDEX_TO, + speciesOptions); + addDataValidation(sheet, + RegisterColumn.SPECIMEN.columnIndex(), + startIndex, + RegisterColumn.SPECIMEN.columnIndex(), + MAX_ROW_INDEX_TO, + specimenOptions); + + setColumnAutoWidth(sheet, 0, RegisterColumn.maxColumnIndex()); + // Auto width ignores cell validation values (e.g. a list of valid entries). So we need + // to set them explicit + setColumnWidth(sheet, RegisterColumn.CONDITION.columnIndex(), maxLength(conditions)); + setColumnWidth(sheet, RegisterColumn.SPECIES.columnIndex(), maxLength(species)); + setColumnWidth(sheet, RegisterColumn.SPECIMEN.columnIndex(), maxLength(specimen)); + setColumnWidth(sheet, RegisterColumn.ANALYTE.columnIndex(), maxLength(analytes)); + setColumnWidth(sheet, RegisterColumn.ANALYSIS.columnIndex(), maxLength(analysisToPerform)); + setColumnAutoWidth(hiddenSheet, 0, 4); + workbook.setActiveSheet(0); + lockSheet(hiddenSheet); + hideSheet(workbook, hiddenSheet); + + return workbook; + } + + static int maxLength(Collection collection) { + return collection.stream().mapToInt(String::length).max().orElse(0); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/templates/sample/SampleBatchUpdateTemplate.java b/user-interface/src/main/java/life/qbic/datamanager/templates/sample/SampleBatchUpdateTemplate.java new file mode 100644 index 000000000..0fb98dc01 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/templates/sample/SampleBatchUpdateTemplate.java @@ -0,0 +1,154 @@ +package life.qbic.datamanager.templates.sample; + +import static life.qbic.datamanager.templates.XLSXTemplateHelper.addDataValidation; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createOptionArea; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.createReadOnlyCellStyle; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.getOrCreateCell; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.getOrCreateRow; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.hideSheet; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.lockSheet; +import static life.qbic.datamanager.templates.XLSXTemplateHelper.setColumnAutoWidth; + +import java.util.List; +import life.qbic.datamanager.parser.sample.EditColumn; +import life.qbic.datamanager.templates.XLSXTemplateHelper; +import life.qbic.projectmanagement.application.sample.PropertyConversion; +import life.qbic.projectmanagement.domain.model.experiment.Condition; +import life.qbic.projectmanagement.domain.model.experiment.ExperimentalGroup; +import life.qbic.projectmanagement.domain.model.sample.Sample; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Name; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +/** + * Sample Batch Template + * + *

    Offers an API to create pre-configured workbooks for sample batch metadata use cases.

    + * + * @since 1.5.0 + */ +public class SampleBatchUpdateTemplate { + + public static final int MAX_ROW_INDEX_TO = 2000; + + + + /** + * Creates a {@link XSSFWorkbook} that contains a prefilled sheet of sample metadata based on the + * provided list of {@link Sample}. + * + * @param samples a list of samples with metadata that will be used to fill the + * spreadsheet + * @param conditions a list of conditions that are available for selection + * @param species a list of species that are available for selection + * @param specimen a list of specimen that are available for selection + * @param analytes a list of analytes that are available for selection + * @param analysisToPerform a list of analysis types available for the sample measurement + * @param experimentalGroups a list of experimental groups the samples belong to + * @return a workbook with a prefilled sheet at tab index 0 that contains the sample metadata + * @since 1.5.0 + */ + public static XSSFWorkbook createUpdateTemplate(List samples, List conditions, + List species, List specimen, List analytes, + List analysisToPerform, List experimentalGroups) { + + XSSFWorkbook workbook = new XSSFWorkbook(); + var readOnlyCellStyle = createReadOnlyCellStyle(workbook); + var readOnlyHeaderStyle = XLSXTemplateHelper.createReadOnlyHeaderCellStyle(workbook); + var boldCellStyle = XLSXTemplateHelper.createBoldCellStyle(workbook); + + var sheet = workbook.createSheet("Sample Metadata"); + + Row header = getOrCreateRow(sheet, 0); + for (EditColumn column : EditColumn.values()) { + var cell = XLSXTemplateHelper.getOrCreateCell(header, column.columnIndex()); + if (column.isMandatory()) { + cell.setCellValue(column.headerName() + "*"); + } else { + cell.setCellValue(column.headerName()); + } + cell.setCellStyle(boldCellStyle); + if (column.isReadOnly()) { + cell.setCellStyle(readOnlyHeaderStyle); + } + } + var startIndex = 1; //start in the second row with index 1. + int rowIndex = startIndex; + for (Sample sample : samples) { + Row row = getOrCreateRow(sheet, rowIndex); + var experimentalGroup = experimentalGroups.stream() + .filter(group -> group.id() == sample.experimentalGroupId()).findFirst().orElseThrow(); + fillRowWithSampleMetadata(row, sample, experimentalGroup.condition(), readOnlyCellStyle); + rowIndex++; + } + + var hiddenSheet = workbook.createSheet("hidden"); + Name analysisToBePerformedOptions = createOptionArea(hiddenSheet, "Analysis to be performed", + analysisToPerform); + Name conditionOptions = createOptionArea(hiddenSheet, "Condition", conditions); + Name analyteOptions = createOptionArea(hiddenSheet, "Analyte", analytes); + Name speciesOptions = createOptionArea(hiddenSheet, "Species", species); + Name specimenOptions = createOptionArea(hiddenSheet, "Specimen", specimen); + + addDataValidation(sheet, + EditColumn.ANALYSIS.columnIndex(), + startIndex, + EditColumn.ANALYSIS.columnIndex(), + MAX_ROW_INDEX_TO, + analysisToBePerformedOptions); + addDataValidation(sheet, + EditColumn.CONDITION.columnIndex(), + startIndex, + EditColumn.CONDITION.columnIndex(), + MAX_ROW_INDEX_TO, + conditionOptions); + addDataValidation(sheet, + EditColumn.ANALYTE.columnIndex(), + startIndex, + EditColumn.ANALYTE.columnIndex(), + MAX_ROW_INDEX_TO, + analyteOptions); + addDataValidation(sheet, + EditColumn.SPECIES.columnIndex(), + startIndex, + EditColumn.SPECIES.columnIndex(), + MAX_ROW_INDEX_TO, + speciesOptions); + addDataValidation(sheet, + EditColumn.SPECIMEN.columnIndex(), + startIndex, + EditColumn.SPECIMEN.columnIndex(), + MAX_ROW_INDEX_TO, + specimenOptions); + + setColumnAutoWidth(sheet, 0, EditColumn.maxColumnIndex()); + workbook.setActiveSheet(0); + lockSheet(hiddenSheet); + hideSheet(workbook, hiddenSheet); + + return workbook; + } + + private static void fillRowWithSampleMetadata(Row row, Sample sample, + Condition condition, CellStyle readOnlyCellStyle) { + for (EditColumn column : EditColumn.values()) { + var value = switch (column) { + case SAMPLE_ID -> sample.sampleCode().code(); + case ANALYSIS -> sample.analysisMethod().abbreviation(); + case SAMPLE_NAME -> sample.label(); + case BIOLOGICAL_REPLICATE -> sample.biologicalReplicate(); + case CONDITION -> PropertyConversion.toString(condition); + case SPECIES -> PropertyConversion.toString(sample.sampleOrigin().getSpecies()); + case ANALYTE -> PropertyConversion.toString(sample.sampleOrigin().getAnalyte()); + case SPECIMEN -> PropertyConversion.toString(sample.sampleOrigin().getSpecimen()); + case COMMENT -> sample.comment().orElse(""); + }; + var cell = getOrCreateCell(row, column.columnIndex()); + cell.setCellValue(value); + if (column.isReadOnly()) { + cell.setCellStyle(readOnlyCellStyle); + } + } + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenComponent.java index 610fcf262..b5fa3dc57 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenComponent.java @@ -358,12 +358,10 @@ public void setToken(String token) { ui.access(() -> { addClassName("success-background-hue"); copyDisclaimerText.setText("Token successfully copied."); - ui.push(); })); copyToClipBoardComponent.addSwitchToCopyIconListener(event -> ui.access(() -> { removeClassName("success-background-hue"); - ui.push(); })); } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/CopyToClipBoardComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/CopyToClipBoardComponent.java index 84b2ad39d..e00499daa 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/CopyToClipBoardComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/CopyToClipBoardComponent.java @@ -70,7 +70,6 @@ private void handleCopyClicked(ComponentEvent componentEvent) { CompletableFuture.runAsync(() -> ui.access(() -> { copyIcon.setVisible(true); copySuccessIcon.setVisible(false); - ui.push(); }), delayedExecutor).thenRun(() -> fireEvent(new SwitchToCopyIconEvent(this, componentEvent.isFromClient()))); } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/download/DownloadContentProvider.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/download/DownloadContentProvider.java deleted file mode 100644 index 39e886f08..000000000 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/download/DownloadContentProvider.java +++ /dev/null @@ -1,10 +0,0 @@ -package life.qbic.datamanager.views.general.download; - -/** - * Provides content and file name for any files created from data and metadata. - */ -public interface DownloadContentProvider { - - public byte[] getContent(); - public String getFileName(); -} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/EditableMultiFileMemoryBuffer.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/EditableMultiFileMemoryBuffer.java similarity index 97% rename from user-interface/src/main/java/life/qbic/datamanager/views/projects/EditableMultiFileMemoryBuffer.java rename to user-interface/src/main/java/life/qbic/datamanager/views/general/upload/EditableMultiFileMemoryBuffer.java index 862a1854a..29fbb8e3c 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/EditableMultiFileMemoryBuffer.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/EditableMultiFileMemoryBuffer.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.views.projects; +package life.qbic.datamanager.views.general.upload; import com.vaadin.flow.component.upload.MultiFileReceiver; import com.vaadin.flow.component.upload.receivers.FileData; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/FileMemoryBuffer.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/FileMemoryBuffer.java new file mode 100644 index 000000000..2da584110 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/FileMemoryBuffer.java @@ -0,0 +1,57 @@ +package life.qbic.datamanager.views.general.upload; + +import static java.util.Objects.nonNull; + +import com.vaadin.flow.component.upload.Receiver; +import com.vaadin.flow.component.upload.receivers.FileData; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Optional; + +/** + * TODO! + * short description + * + *

    detailed description

    + * + * @since + */ +public class FileMemoryBuffer implements Receiver { + + private FileData fileData; + + @Override + public OutputStream receiveUpload(String fileName, String mimeType) { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + this.fileData = new FileData(fileName, mimeType, byteArrayOutputStream); + return byteArrayOutputStream; + } + + public boolean hasUploadedData() { + return nonNull(fileData); + } + + public Optional getInputStream() { + return Optional.ofNullable(fileData) + .map(FileData::getOutputBuffer) + .filter(ByteArrayOutputStream.class::isInstance) + .map(ByteArrayOutputStream.class::cast) + .map(ByteArrayOutputStream::toByteArray) + .map(ByteArrayInputStream::new); + } + + public Optional getFileName() { + return Optional.ofNullable(fileData.getFileName()); + } + + public Optional getMimeType() { + return Optional.ofNullable(fileData.getMimeType()); + } + + public void clear() { + fileData = null; + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/UploadWithDisplay.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/UploadWithDisplay.java new file mode 100644 index 000000000..fc4dd781c --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/upload/UploadWithDisplay.java @@ -0,0 +1,268 @@ +package life.qbic.datamanager.views.general.upload; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.upload.Upload; +import com.vaadin.flow.component.upload.UploadI18N; +import com.vaadin.flow.component.upload.UploadI18N.Error; +import com.vaadin.flow.shared.Registration; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +/** + * A component supporting a file upload and a display of the currently uploaded file. + *

    + * Only supports uploading one file at a time, decreasing complexity of display behaviour and data. + * + * @since 1.4.0 + */ +public class UploadWithDisplay extends Div { + + private final Upload upload; + private final Div errorArea; + private final Div displayContainer; + private final Span displayContainerTitle; + private final FileMemoryBuffer fileMemoryBuffer; + + public UploadWithDisplay(int maxFileSize) { + this(maxFileSize, new FileType[]{}); + } + + public record FileType(String extension, String mimeType) { + } + + public UploadWithDisplay(int maxFileSize, FileType[] fileTypes) { + errorArea = new Div(); + displayContainer = new Div(); + fileMemoryBuffer = new FileMemoryBuffer(); + upload = new Upload(); + var restrictions = new Div(); + + errorArea.addClassName("error-message-box"); + displayContainer.addClassName("uploaded-items-section"); + displayContainer.setVisible(false); + + displayContainerTitle = new Span("Uploaded file"); + displayContainerTitle.addClassName("section-title"); + displayContainerTitle.setVisible(false); + + restrictions.addClassName("restrictions"); + var allowedExtensions = Arrays.stream(fileTypes) + .map(FileType::extension) + .map(it -> it.startsWith(".") ? it : "." + it) + .distinct() + .toList(); + if (fileTypes.length > 0) { + restrictions.add(new Div("Supported file formats: " + String.join(", ", allowedExtensions))); + restrictions.add(new Div("Maximum file size: " + formatFileSize(maxFileSize))); + } + + upload.setAcceptedFileTypes( + fileTypes.length > 0 ? Arrays.stream(fileTypes) + .map(FileType::mimeType) + .filter(it -> !it.isBlank()) + .toArray(String[]::new) : null); + + upload.setMaxFileSize(maxFileSize); + upload.setMaxFiles(1); // we only allow one file + upload.setReceiver(fileMemoryBuffer); + upload.addFileRemovedListener(fileRemovedEvent -> { + fileMemoryBuffer.clear(); + displayContainer.removeAll(); + displayContainerTitle.setVisible(false); + displayContainer.setVisible(false); + fireEvent(new UploadRemovedEvent(this, fileRemovedEvent.isFromClient())); + }); + + Error errorTranslation = new Error(); + errorTranslation.setFileIsTooBig( + "The provided file is too big. Please make sure your file is smaller than " + + formatFileSize(maxFileSize)); + errorTranslation.setTooManyFiles("Please upload one file at a time."); + errorTranslation.setIncorrectFileType( + "Unsupported file type. Supported file types are " + String.join(", ", allowedExtensions)); + UploadI18N uploadI18N = new UploadI18N(); + uploadI18N.setError(errorTranslation); + upload.setI18n(uploadI18N); + + upload.addSucceededListener(it -> fireEvent(new SucceededEvent(this, it.isFromClient()))); + upload.addFailedListener(it -> fireEvent(new FailedEvent(this, it.isFromClient()))); + upload.addFileRejectedListener(fileRejected -> { + errorArea.setVisible(true); + errorArea.setText(fileRejected.getErrorMessage()); + }); + upload.addFinishedListener(it -> errorArea.setVisible(false)); + add(errorArea, upload, restrictions, displayContainerTitle, displayContainer); + } + + private static String formatFileSize(int bytes) { + if (bytes > Math.pow(1024, 2)) { + return bytes / Math.pow(1024, 2) + " MB"; + } + if (bytes > 1024) { + return bytes / 1024d + " KB"; + } + return bytes + " B"; + } + + public Registration addSuccessListener(ComponentEventListener listener) { + return addListener(SucceededEvent.class, listener); + } + + public Registration addFailureListener(ComponentEventListener listener) { + return addListener(FailedEvent.class, listener); + } + + public Registration addRemovedListener(ComponentEventListener listener) { + return addListener(UploadRemovedEvent.class, listener); + } + + /** + * Sets component to display the currently available file. This component will be cleared when the + * file is removed from the upload component. + * + * @param uploadProgressDisplay a component to display + * @param the type of component to display + */ + public void setDisplay(T uploadProgressDisplay) { + displayContainer.removeAll(); + if (uploadProgressDisplay == null) { + displayContainerTitle.setVisible(false); + displayContainer.setVisible(false); + return; + } + uploadProgressDisplay.addClassName("uploaded-item"); + displayContainer.add(uploadProgressDisplay); + displayContainerTitle.setVisible(true); + displayContainer.setVisible(true); + } + + /** + * Removes component from display. + *

    + * If the component is not displayed, does nothing. + * @param display + * @param + */ + public void removeDisplay(T display) { + displayContainer.remove(display); + displayContainerTitle.setVisible(false); + displayContainer.setVisible(false); + fireEvent(new UploadRemovedEvent(this, false)); + } + + /** + * Clears the upload as well as resets the display. + */ + public void clear() { + upload.clearFileList(); + displayContainer.removeAll(); + displayContainerTitle.setVisible(false); + displayContainer.setVisible(false); + errorArea.removeAll(); + fileMemoryBuffer.clear(); + } + + /** + * @return the uploaded data or {@link Optional#empty()} is nothing was uploaded yet. + */ + public Optional getUploadedData() { + if (fileMemoryBuffer.hasUploadedData()) { + var fileName = fileMemoryBuffer.getFileName().orElseThrow(); + var inputStream = fileMemoryBuffer.getInputStream().orElseThrow(); + var mimeType = fileMemoryBuffer.getMimeType().orElseThrow(); + return Optional.of(new UploadedData(fileName, inputStream, mimeType)); + } + return Optional.empty(); + } + + /** + * A representation of uploaded data + * @param fileName the name of the uploaded file + * @param inputStream the contents of the file + * @param mimeType the type of file + */ + public record UploadedData(String fileName, InputStream inputStream, String mimeType) { + + /** + * {@inheritDoc} + *

    + * This method ignores the file content and only compares filename and mime tpye. + * @param o the reference object with which to compare. + * @return {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof UploadedData that)) { + return false; + } + + return Objects.equals(fileName, that.fileName) && Objects.equals(mimeType, + that.mimeType); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(fileName); + result = 31 * result + Objects.hashCode(mimeType); + return result; + } + } + + public static class SucceededEvent extends ComponentEvent { + + /** + * Creates a new event using the given source and indicator whether the event originated from + * the client side or the server side. + * + * @param source the source component + * @param fromClient true if the event originated from the client + * side, false otherwise + */ + public SucceededEvent(UploadWithDisplay source, boolean fromClient) { + super(source, fromClient); + } + } + + public static class FailedEvent extends ComponentEvent { + + /** + * Creates a new event using the given source and indicator whether the event originated from + * the client side or the server side. + * + * @param source the source component + * @param fromClient true if the event originated from the client + * side, false otherwise + */ + public FailedEvent(UploadWithDisplay source, boolean fromClient) { + super(source, fromClient); + } + } + + /** + * Indicates that the upload was removed. + */ + public static class UploadRemovedEvent extends ComponentEvent { + + /** + * Creates a new event using the given source and indicator whether the event originated from + * the client side or the server side. + * + * @param source the source component + * @param fromClient true if the event originated from the client + * side, false otherwise + */ + public UploadRemovedEvent(UploadWithDisplay source, boolean fromClient) { + super(source, fromClient); + } + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java index 6ae718252..4e25f025e 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java @@ -239,7 +239,7 @@ private static void routeToProjectOverview() { log.debug("Routing to ProjectOverview page"); } - private static void routeToProject(ProjectId projectId) { +private static void routeToProject(ProjectId projectId) { RouteParameters routeParameters = new RouteParameters( new RouteParam(PROJECT_ID_ROUTE_PARAMETER, projectId.value())); //getUI is not possible on the ProjectSideNavigationComponent directly in a static context diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/notifications/CancelConfirmationDialogFactory.java b/user-interface/src/main/java/life/qbic/datamanager/views/notifications/CancelConfirmationDialogFactory.java index b85938a5a..1e6285f23 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/notifications/CancelConfirmationDialogFactory.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/notifications/CancelConfirmationDialogFactory.java @@ -86,7 +86,7 @@ private MessageType parseMessageType(String key, Locale locale) { EMPTY_PARAMETERS, locale).strip().toUpperCase(); return MessageType.valueOf(messageType); } catch (NoSuchMessageException e) { - log.error("No message type specified for %s." + key, e); + log.warn("No message type specified for %s." + key); return DEFAULT_MESSAGE_TYPE; } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java index a487b13b0..78b8b15e5 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java @@ -28,12 +28,14 @@ import life.qbic.application.commons.ApplicationException; import life.qbic.application.commons.ApplicationException.ErrorCode; import life.qbic.application.commons.Result; +import life.qbic.datamanager.download.DownloadProvider; +import life.qbic.datamanager.templates.measurement.NGSMeasurementEditTemplate; +import life.qbic.datamanager.templates.measurement.ProteomicsMeasurementEditTemplate; import life.qbic.datamanager.views.AppRoutes.Projects; import life.qbic.datamanager.views.Context; import life.qbic.datamanager.views.general.Disclaimer; import life.qbic.datamanager.views.general.InfoBox; import life.qbic.datamanager.views.general.Main; -import life.qbic.datamanager.views.general.download.DownloadProvider; import life.qbic.datamanager.views.general.download.MeasurementTemplateDownload; import life.qbic.datamanager.views.notifications.CancelConfirmationDialogFactory; import life.qbic.datamanager.views.notifications.ErrorMessage; @@ -42,8 +44,6 @@ import life.qbic.datamanager.views.projects.project.measurements.MeasurementMetadataUploadDialog.MODE; import life.qbic.datamanager.views.projects.project.measurements.MeasurementMetadataUploadDialog.MeasurementMetadataUpload; import life.qbic.datamanager.views.projects.project.measurements.MeasurementTemplateListComponent.DownloadMeasurementTemplateEvent; -import life.qbic.datamanager.views.projects.project.measurements.download.NGSMeasurementContentProvider; -import life.qbic.datamanager.views.projects.project.measurements.download.ProteomicsMeasurementContentProvider; import life.qbic.logging.api.Logger; import life.qbic.logging.service.LoggerFactory; import life.qbic.projectmanagement.application.ProjectInformationService; @@ -95,8 +95,8 @@ public class MeasurementMain extends Main implements BeforeEnterObserver { private final Div content = new Div(); private final InfoBox rawDataAvailableInfo = new InfoBox(); private final Div noMeasurementDisclaimer; - private final ProteomicsMeasurementContentProvider proteomicsMeasurementContentProvider; - private final NGSMeasurementContentProvider ngsMeasurementContentProvider; + private final ProteomicsMeasurementEditTemplate proteomicsMeasurementEditTemplate; + private final NGSMeasurementEditTemplate ngsMeasurementEditTemplate; private final DownloadProvider ngsDownloadProvider; private final DownloadProvider proteomicsDownloadProvider; private final ProjectInformationService projectInformationService; @@ -120,10 +120,10 @@ public MeasurementMain( this.measurementTemplateListComponent = measurementTemplateListComponent; this.measurementService = measurementService; this.measurementPresenter = measurementPresenter; - this.proteomicsMeasurementContentProvider = new ProteomicsMeasurementContentProvider(); - this.ngsMeasurementContentProvider = new NGSMeasurementContentProvider(); - this.ngsDownloadProvider = new DownloadProvider(ngsMeasurementContentProvider); - this.proteomicsDownloadProvider = new DownloadProvider(proteomicsMeasurementContentProvider); + this.proteomicsMeasurementEditTemplate = new ProteomicsMeasurementEditTemplate(); + this.ngsMeasurementEditTemplate = new NGSMeasurementEditTemplate(); + this.ngsDownloadProvider = new DownloadProvider(ngsMeasurementEditTemplate); + this.proteomicsDownloadProvider = new DownloadProvider(proteomicsMeasurementEditTemplate); this.measurementValidationService = measurementValidationService; this.sampleInformationService = Objects.requireNonNull(sampleInformationService); this.projectInformationService = projectInformationService; @@ -371,7 +371,7 @@ private void downloadProteomicsMetadata() { .flatMap(Collection::stream) .sorted(Comparator.comparing(ProteomicsMeasurementEntry::measurementCode, natOrder) .thenComparing(ptx -> ptx.sampleInformation().sampleId(), natOrder)).toList(); - proteomicsMeasurementContentProvider.setMeasurements(result, + proteomicsMeasurementEditTemplate.setMeasurements(result, projectInformationService.find(context.projectId().orElseThrow()).orElseThrow() .getProjectCode().value()); proteomicsDownloadProvider.trigger(); @@ -390,7 +390,7 @@ private void downloadNGSMetadata() { // sort by measurement codes first, then by sample codes .sorted(Comparator.comparing(NGSMeasurementEntry::measurementCode, natOrder) .thenComparing(ngs -> ngs.sampleInformation().sampleId(), natOrder)).toList(); - ngsMeasurementContentProvider.setMeasurements(result, + ngsMeasurementEditTemplate.setMeasurements(result, projectInformationService.find(context.projectId().orElseThrow()).orElseThrow() .getProjectCode().value()); ngsDownloadProvider.trigger(); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMetadataUploadDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMetadataUploadDialog.java index b902de2bb..6a17c423b 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMetadataUploadDialog.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMetadataUploadDialog.java @@ -9,27 +9,29 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.ListItem; import com.vaadin.flow.component.html.OrderedList; +import com.vaadin.flow.component.html.OrderedList.NumberingType; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.progressbar.ProgressBar; import com.vaadin.flow.component.upload.FailedEvent; import com.vaadin.flow.component.upload.FileRejectedEvent; +import com.vaadin.flow.component.upload.FileRemovedEvent; import com.vaadin.flow.component.upload.SucceededEvent; import com.vaadin.flow.component.upload.Upload; -import com.vaadin.flow.dom.DomEvent; import com.vaadin.flow.shared.Registration; -import elemental.json.JsonObject; import java.io.InputStream; import java.io.Serial; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.stream.Collectors; import life.qbic.datamanager.parser.MeasurementMetadataConverter.MissingSampleIdException; import life.qbic.datamanager.parser.MeasurementMetadataConverter.UnknownMetadataTypeException; import life.qbic.datamanager.parser.MetadataConverter; @@ -38,15 +40,15 @@ import life.qbic.datamanager.parser.xlsx.XLSXParser; import life.qbic.datamanager.views.general.InfoBox; import life.qbic.datamanager.views.general.WizardDialogWindow; +import life.qbic.datamanager.views.general.upload.EditableMultiFileMemoryBuffer; import life.qbic.datamanager.views.notifications.CancelConfirmationDialogFactory; import life.qbic.datamanager.views.notifications.ErrorMessage; import life.qbic.datamanager.views.notifications.StyledNotification; -import life.qbic.datamanager.views.projects.EditableMultiFileMemoryBuffer; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.application.measurement.MeasurementMetadata; import life.qbic.projectmanagement.application.measurement.NGSMeasurementMetadata; import life.qbic.projectmanagement.application.measurement.ProteomicsMeasurementMetadata; import life.qbic.projectmanagement.application.measurement.validation.MeasurementValidationService; -import life.qbic.projectmanagement.application.measurement.validation.ValidationResult; import life.qbic.projectmanagement.domain.model.experiment.Experiment; import life.qbic.projectmanagement.domain.model.project.ProjectId; import org.springframework.util.StringUtils; @@ -66,7 +68,6 @@ public class MeasurementMetadataUploadDialog extends WizardDialogWindow { public static final int MAX_FILE_SIZE_BYTES = (int) (Math.pow(1024, 2) * 16); @Serial private static final long serialVersionUID = -8253078073427291947L; - private static final String VAADIN_FILENAME_EVENT = "event.detail.file.name"; private final MeasurementValidationService measurementValidationService; private final CancelConfirmationDialogFactory cancelConfirmationDialogFactory; @@ -78,6 +79,27 @@ public class MeasurementMetadataUploadDialog extends WizardDialogWindow { private final UploadProgressDisplay uploadProgressDisplay; private final UploadItemsDisplay uploadItemsDisplay; + private enum AcceptedFileType { + EXCEL("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + TSV("text/tab-separated-values", "text/plain"); + private final List mimeTypes; + + AcceptedFileType(String... mimeTypes) { + this.mimeTypes = List.of(mimeTypes); + } + + static Optional forMimeType(String mimeType) { + return Arrays.stream(values()) + .filter(it -> it.mimeTypes.contains(mimeType)) + .findFirst(); + } + + static Set allMimeTypes() { + return Arrays.stream(values()).flatMap(it -> it.mimeTypes.stream()) + .collect(Collectors.toUnmodifiableSet()); + } + } + public MeasurementMetadataUploadDialog(MeasurementValidationService measurementValidationService, CancelConfirmationDialogFactory cancelConfirmationDialogFactory, MODE mode, ProjectId projectId) { @@ -92,8 +114,7 @@ public MeasurementMetadataUploadDialog(MeasurementValidationService measurementV this.measurementMetadataUploads = new ArrayList<>(); this.measurementFileItems = new ArrayList<>(); Upload upload = new Upload(uploadBuffer); - upload.setAcceptedFileTypes("text/tab-separated-values", "text/plain", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + upload.setAcceptedFileTypes(AcceptedFileType.allMimeTypes().toArray(String[]::new)); upload.setMaxFileSize(MAX_FILE_SIZE_BYTES); setModeBasedLabels(); uploadItemsDisplay = new UploadItemsDisplay(upload); @@ -105,12 +126,8 @@ public MeasurementMetadataUploadDialog(MeasurementValidationService measurementV upload.addSucceededListener(this::onUploadSucceeded); upload.addFileRejectedListener(this::onFileRejected); upload.addFailedListener(this::onUploadFailed); + upload.addFileRemovedListener(this::onFileRemoved); setEscAction(this::onCanceled); - // Synchronise the Vaadin upload component with the purchase list display - // When a file is removed from the upload component, we also want to remove it properly from memory - // and from any additional display - upload.getElement().addEventListener("file-remove", this::onFileRemoved) - .addEventData(VAADIN_FILENAME_EVENT); addClassName("measurement-upload-dialog"); } @@ -135,11 +152,8 @@ public MODE getMode() { return mode; } - private void onFileRemoved(DomEvent domEvent) { - JsonObject jsonObject = domEvent.getEventData(); - var fileName = jsonObject.getString(VAADIN_FILENAME_EVENT); - removeFile(fileName); - + private void onFileRemoved(FileRemovedEvent fileRemovedEvent) { + removeFile(fileRemovedEvent.getFileName()); } private void showFile(MeasurementFileItem measurementFileItem) { @@ -166,7 +180,7 @@ private void onUploadFailed(FailedEvent failedEvent) { private MeasurementValidationReport validate(List metadata) { if (metadata == null || metadata.isEmpty()) { return new MeasurementValidationReport(0, - ValidationResult.withFailures(0, List.of("The metadata sheet seems to be empty"))); + ValidationResult.withFailures(List.of("The metadata sheet seems to be empty"))); } if (metadata.get(0) instanceof NGSMeasurementMetadata) { return validateNGS((List) metadata); @@ -184,20 +198,25 @@ private ParsingResult parseTSV(InputStream inputStream) { private void onUploadSucceeded(SucceededEvent succeededEvent) { var fileName = succeededEvent.getFileName(); - ParsingResult parsingResult; - if (fileName.endsWith(".xlsx")) { - parsingResult = parseXLSX(uploadBuffer.inputStream(fileName).orElseThrow()); - } else if (fileName.endsWith(".tsv") || fileName.endsWith(".txt")) { - parsingResult = parseTSV(uploadBuffer.inputStream(fileName).orElseThrow()); - } else { + Optional knownFileType = AcceptedFileType.forMimeType( + succeededEvent.getMIMEType()); + if (knownFileType.isEmpty()) { displayError(succeededEvent.getFileName(), "Unsupported file type. Please make sure to upload a TSV or XLSX file."); return; } + var parsingResult = switch (knownFileType.get()) { + case EXCEL -> parseXLSX(uploadBuffer.inputStream(fileName).orElseThrow()); + case TSV -> parseTSV(uploadBuffer.inputStream(fileName).orElseThrow()); + }; List result; try { - result = MetadataConverter.measurementConverter() - .convert(parsingResult, mode.equals(MODE.ADD)); + result = switch (mode) { + case ADD -> MetadataConverter.measurementConverter() + .convertRegister(parsingResult); + case EDIT -> MetadataConverter.measurementConverter() + .convertEdit(parsingResult); + }; } catch ( UnknownMetadataTypeException e) { // we want to display this in the dialog, not via the notification system displayError(succeededEvent.getFileName(), @@ -229,7 +248,7 @@ private void displayError(String fileName, String reason) { fileName, Collections.emptyList()); MeasurementFileItem measurementFileItem = new MeasurementFileItem( fileName, - new MeasurementValidationReport(1, ValidationResult.withFailures(1, List.of( + new MeasurementValidationReport(1, ValidationResult.withFailures(List.of( reason)))); addFile(measurementFileItem, metadataUpload); } @@ -242,7 +261,7 @@ private void addFile(MeasurementFileItem measurementFileItem, } private MeasurementValidationReport validateNGS(List content) { - var validationResult = ValidationResult.successful(0); + var validationResult = ValidationResult.successful(); ConcurrentLinkedDeque concurrentLinkedDeque = new ConcurrentLinkedDeque<>(); List> tasks = new ArrayList<>(); for (NGSMeasurementMetadata metaDatum : content) { @@ -257,7 +276,7 @@ private MeasurementValidationReport validateNGS(List con private MeasurementValidationReport validatePxP(List content) { - var validationResult = ValidationResult.successful(0); + var validationResult = ValidationResult.successful(); ConcurrentLinkedDeque concurrentLinkedDeque = new ConcurrentLinkedDeque<>(); List> tasks = new ArrayList<>(); for (ProteomicsMeasurementMetadata metaDatum : content) { @@ -463,7 +482,7 @@ private Div createInvalidDisplayBox(Collection invalidMeasurements) { OrderedList invalidMeasurementsList = new OrderedList( invalidMeasurements.stream().map(ListItem::new).toArray(ListItem[]::new)); invalidMeasurementsList.addClassName("invalid-measurement-list"); - invalidMeasurementsList.setType(OrderedList.NumberingType.NUMBER); + invalidMeasurementsList.setType(NumberingType.NUMBER); validationDetails.add(invalidMeasurementsList); box.add(header, validationDetails, instruction); return box; @@ -579,7 +598,7 @@ private static class UploadProgressDisplay extends Div { public UploadProgressDisplay(MODE mode) { - Objects.requireNonNull(mode, "Mode cannot be null"); + requireNonNull(mode, "Mode cannot be null"); String modeBasedTask = (mode == MODE.ADD ? "register" : "update"); Span title = new Span( String.format("%s" + " the measurement data", StringUtils.capitalize(modeBasedTask))); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementNGSValidationExecutor.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementNGSValidationExecutor.java index 6c030ec11..8f4727d07 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementNGSValidationExecutor.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementNGSValidationExecutor.java @@ -4,7 +4,7 @@ import java.util.concurrent.CompletableFuture; import life.qbic.projectmanagement.application.measurement.NGSMeasurementMetadata; import life.qbic.projectmanagement.application.measurement.validation.MeasurementValidationService; -import life.qbic.projectmanagement.application.measurement.validation.ValidationResult; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.domain.model.project.ProjectId; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementProteomicsValidationExecutor.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementProteomicsValidationExecutor.java index 2d95b27ff..25b03a604 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementProteomicsValidationExecutor.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementProteomicsValidationExecutor.java @@ -4,9 +4,8 @@ import java.util.concurrent.CompletableFuture; import life.qbic.projectmanagement.application.measurement.ProteomicsMeasurementMetadata; import life.qbic.projectmanagement.application.measurement.validation.MeasurementValidationService; -import life.qbic.projectmanagement.application.measurement.validation.ValidationResult; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.domain.model.project.ProjectId; -import org.apache.poi.ss.formula.functions.T; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Component; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementValidationExecutor.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementValidationExecutor.java index ff17bab52..97ea93249 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementValidationExecutor.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementValidationExecutor.java @@ -3,7 +3,7 @@ import java.util.concurrent.CompletableFuture; import life.qbic.projectmanagement.application.measurement.MeasurementMetadata; import life.qbic.projectmanagement.application.measurement.validation.MeasurementValidationService; -import life.qbic.projectmanagement.application.measurement.validation.ValidationResult; +import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.domain.model.project.ProjectId; /** diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/download/NGSMeasurementContentProvider.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/download/NGSMeasurementContentProvider.java deleted file mode 100644 index 6254294d5..000000000 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/measurements/download/NGSMeasurementContentProvider.java +++ /dev/null @@ -1,312 +0,0 @@ -package life.qbic.datamanager.views.projects.project.measurements.download; - -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.createOptionArea; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.getOrCreateCell; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.getOrCreateRow; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.hideSheet; -import static life.qbic.datamanager.spreadsheet.XLSXTemplateHelper.lockSheet; -import static life.qbic.logging.service.LoggerFactory.logger; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import life.qbic.application.commons.ApplicationException; -import life.qbic.application.commons.ApplicationException.ErrorCode; -import life.qbic.datamanager.spreadsheet.XLSXTemplateHelper; -import life.qbic.datamanager.views.general.download.DownloadContentProvider; -import life.qbic.datamanager.views.projects.project.measurements.NGSMeasurementEntry; -import life.qbic.logging.api.Logger; -import life.qbic.projectmanagement.application.measurement.NGSMeasurementMetadata; -import life.qbic.projectmanagement.domain.model.measurement.NGSMeasurement; -import org.apache.poi.ss.usermodel.Cell; -import org.apache.poi.ss.usermodel.CellStyle; -import org.apache.poi.ss.usermodel.FillPatternType; -import org.apache.poi.ss.usermodel.Font; -import org.apache.poi.ss.usermodel.Name; -import org.apache.poi.ss.usermodel.Row; -import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap; -import org.apache.poi.xssf.usermodel.XSSFColor; -import org.apache.poi.xssf.usermodel.XSSFFont; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; - -/** - * NGS Measurement Content Provider - *

    - * Implementation of the {@link DownloadContentProvider} providing the content and file name for any - * files created from {@link NGSMeasurement} and {@link NGSMeasurementMetadata} - *

    - */ -public class NGSMeasurementContentProvider implements DownloadContentProvider { - - private static final String FILE_NAME_SUFFIX = "ngs_measurements.xlsx"; - private static final Logger log = logger(NGSMeasurementContentProvider.class); - private static final byte[] DARK_GREY = {119, 119, 119}; - private static final byte[] LIGHT_GREY = {(byte) 220, (byte) 220, (byte) 220}; - private static CellStyle readOnlyCellStyle; - private static CellStyle readOnlyHeaderStyle; - private static CellStyle boldStyle; - private final List measurements = new LinkedList<>(); - private static final String DEFAULT_FILE_NAME_PREFIX = "QBiC"; - private String fileNamePrefix = DEFAULT_FILE_NAME_PREFIX; - private static final int DEFAULT_GENERATED_ROW_COUNT = 200; - - private enum SequencingReadType { - SINGLE_END("single-end"), - PAIRED_END("paired-end"); - private final String presentationString; - - SequencingReadType(String presentationString) { - this.presentationString = presentationString; - } - - static List getOptions() { - return Arrays.stream(values()).map(it -> it.presentationString).toList(); - } - } - - private static void setAutoWidth(Sheet sheet) { - for (int col = 0; col <= NGSMeasurementColumns.values().length; col++) { - sheet.autoSizeColumn(col); - } - } - - private static void formatHeader(Row header) { - for (NGSMeasurementColumns value : NGSMeasurementColumns.values()) { - var cell = header.createCell(value.columnIndex()); - cell.setCellValue(value.headerName()); - setHeaderStyle(cell, value.readOnly()); - } - } - - private static void setHeaderStyle(Cell cell, boolean isReadOnly) { - if (isReadOnly) { - cell.setCellStyle(readOnlyHeaderStyle); - } else { - cell.setCellStyle(boldStyle); - } - } - - private static void setCellStyle(Cell cell, boolean isReadOnly) { - if (isReadOnly) { - cell.setCellStyle(readOnlyCellStyle); - } - } - - private static void writeMeasurementIntoRow(NGSMeasurementEntry ngsMeasurementEntry, - Row entryRow) { - - for (NGSMeasurementColumns measurementColumn : NGSMeasurementColumns.values()) { - var value = switch (measurementColumn) { - case MEASUREMENT_ID -> ngsMeasurementEntry.measurementCode(); - case SAMPLE_ID -> ngsMeasurementEntry.sampleInformation().sampleId(); - case SAMPLE_NAME -> ngsMeasurementEntry.sampleInformation().sampleName(); - case POOL_GROUP -> ngsMeasurementEntry.samplePoolGroup(); - case ORGANISATION_ID -> ngsMeasurementEntry.organisationId(); - case ORGANISATION_NAME -> ngsMeasurementEntry.organisationName(); - case FACILITY -> ngsMeasurementEntry.facility(); - case INSTRUMENT -> ngsMeasurementEntry.instrumentCURI(); - case INSTRUMENT_NAME -> ngsMeasurementEntry.instrumentName(); - case SEQUENCING_READ_TYPE -> ngsMeasurementEntry.readType(); - case LIBRARY_KIT -> ngsMeasurementEntry.libraryKit(); - case FLOW_CELL -> ngsMeasurementEntry.flowCell(); - case SEQUENCING_RUN_PROTOCOL -> ngsMeasurementEntry.runProtocol(); - case INDEX_I7 -> ngsMeasurementEntry.indexI7(); - case INDEX_I5 -> ngsMeasurementEntry.indexI5(); - case COMMENT -> ngsMeasurementEntry.comment(); - }; - var cell = getOrCreateCell(entryRow, measurementColumn.columnIndex()); - cell.setCellValue(value); - if (measurementColumn.readOnly()) { - cell.setCellStyle(readOnlyCellStyle); - } - } - - - } - - public void setMeasurements(List measurements, String fileNamePrefix) { - this.measurements.clear(); - this.measurements.addAll(measurements); - this.fileNamePrefix = fileNamePrefix.trim(); - } - - private void defineBoldStyle(Workbook workbook) { - boldStyle = workbook.createCellStyle(); - Font fontBold = workbook.createFont(); - fontBold.setBold(true); - boldStyle.setFont(fontBold); - } - - private void defineReadOnlyCellStyle(Workbook workbook) { - readOnlyCellStyle = workbook.createCellStyle(); - readOnlyCellStyle.setFillForegroundColor( - new XSSFColor(LIGHT_GREY, new DefaultIndexedColorMap())); - readOnlyCellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); - XSSFFont font = (XSSFFont) workbook.createFont(); - font.setColor(new XSSFColor(DARK_GREY, new DefaultIndexedColorMap())); - readOnlyCellStyle.setFont(font); - } - - private void defineReadOnlyHeaderStyle(Workbook workbook) { - readOnlyHeaderStyle = workbook.createCellStyle(); - readOnlyHeaderStyle.setFillForegroundColor( - new XSSFColor(LIGHT_GREY, new DefaultIndexedColorMap())); - readOnlyHeaderStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); - XSSFFont fontHeader = (XSSFFont) workbook.createFont(); - fontHeader.setBold(true); - fontHeader.setColor(new XSSFColor(DARK_GREY, new DefaultIndexedColorMap())); - readOnlyHeaderStyle.setFont(fontHeader); - } - - - @Override - public byte[] getContent() { - if (measurements.isEmpty()) { - return new byte[0]; - } - - ByteArrayOutputStream byteArrayOutputStream; - - try (Workbook workbook = new XSSFWorkbook()) { - defineReadOnlyHeaderStyle(workbook); - defineReadOnlyCellStyle(workbook); - defineBoldStyle(workbook); - - Sheet sheet = workbook.createSheet("NGS Measurement Metadata"); - - Row header = getOrCreateRow(sheet, 0); - for (NGSMeasurementColumns value : NGSMeasurementColumns.values()) { - var cell = getOrCreateCell(header, value.columnIndex()); - cell.setCellValue(value.headerName()); - setHeaderStyle(cell, value.readOnly()); - } - - var startIndex = 1; // start in row number 2 with index 1 as the header row has number 1 index 0 - int rowIndex = startIndex; - for (NGSMeasurementEntry measurement : measurements) { - Row row = getOrCreateRow(sheet, rowIndex); - writeMeasurementIntoRow(measurement, row); - rowIndex++; - } - - var generatedRowCount = rowIndex - startIndex; - assert generatedRowCount == measurements.size() : "all measurements have a corresponding row"; - - // make sure to create the visible sheet first - Sheet hiddenSheet = workbook.createSheet("hidden"); - Name sequencingReadTypeArea = createOptionArea(hiddenSheet, - "Sequencing read type", SequencingReadType.getOptions()); - - XLSXTemplateHelper.addDataValidation(sheet, - NGSMeasurementColumns.SEQUENCING_READ_TYPE.columnIndex(), - startIndex, - NGSMeasurementColumns.SEQUENCING_READ_TYPE.columnIndex(), - DEFAULT_GENERATED_ROW_COUNT - 1, - sequencingReadTypeArea); - - setAutoWidth(sheet); - workbook.setActiveSheet(0); - - lockSheet(hiddenSheet); - hideSheet(workbook, hiddenSheet); - - byteArrayOutputStream = new ByteArrayOutputStream(); - workbook.write(byteArrayOutputStream); - } catch (IOException e) { - log.error(e.getMessage(), e); - throw new ApplicationException(ErrorCode.GENERAL, null); - } - - return byteArrayOutputStream.toByteArray(); - } - - @Override - public String getFileName() { - return String.join("_", fileNamePrefix, FILE_NAME_SUFFIX); - } - - /** - * NGS Measurement Columns - * - *

    Enumeration of the columns shown in the file used for NGS measurement registration and edit - * in the context of measurement file based upload. - * Provides the name of the header column, the column index and if the column should be set to - * readOnly in the generated sheet - *

    - */ - enum NGSMeasurementColumns { - - MEASUREMENT_ID("Measurement ID", 0, - true), - SAMPLE_ID("QBiC Sample Id", 1, - true), - SAMPLE_NAME( - "Sample Name", 2, - true), - POOL_GROUP("Sample Pool Group", 3, - true), - ORGANISATION_ID("Organisation ID", 4, - false), - ORGANISATION_NAME("Organisation Name", 5, - true), - FACILITY("Facility", 6, - false), - INSTRUMENT("Instrument", 7, - false), - INSTRUMENT_NAME("Instrument Name", 8, - true), - SEQUENCING_READ_TYPE("Sequencing Read Type", 9, - false), - LIBRARY_KIT("Library Kit", 10, - false), - FLOW_CELL("Flow Cell", 11, - false), - SEQUENCING_RUN_PROTOCOL("Sequencing Run Protocol", 12, - false), - INDEX_I7("Index i7", 13, - false), - INDEX_I5("Index i5", 14, - false), - COMMENT("Comment", 15, - false), - ; - - private final String headerName; - private final int columnIndex; - private final boolean readOnly; - - static int maxColumnIndex() { - return Arrays.stream(values()) - .mapToInt(NGSMeasurementColumns::columnIndex) - .max().orElse(0); - } - - /** - * @param headerName the name in the header - * @param columnIndex the index of the column this property is in - * @param readOnly is the property read only - */ - NGSMeasurementColumns(String headerName, int columnIndex, boolean readOnly) { - this.headerName = headerName; - this.columnIndex = columnIndex; - this.readOnly = readOnly; - } - - public String headerName() { - return headerName; - } - - public int columnIndex() { - return columnIndex; - } - - public boolean readOnly() { - return readOnly; - } - - } -} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/ontology/OntologyLookupComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/ontology/OntologyLookupComponent.java index aa9b3096a..327a50b2e 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/ontology/OntologyLookupComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/ontology/OntologyLookupComponent.java @@ -194,11 +194,9 @@ private Span createHeader(String label, String curieText) { ui.getPushConfiguration().setPushMode(PushMode.MANUAL); copyToClipBoardComponent.addSwitchToSuccessfulCopyIconListener(event -> ui.access(() -> { addClassName("success-background-hue"); - ui.push(); })); copyToClipBoardComponent.addSwitchToCopyIconListener(event -> ui.access(() -> { removeClassName("success-background-hue"); - ui.push(); })); header.addClassName("header"); return header; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataMain.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataMain.java index 64ca228ce..4c5a79dfc 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataMain.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataMain.java @@ -19,12 +19,12 @@ import java.util.List; import java.util.Objects; import life.qbic.application.commons.ApplicationException; +import life.qbic.datamanager.download.DownloadProvider; import life.qbic.datamanager.views.AppRoutes.Projects; import life.qbic.datamanager.views.Context; import life.qbic.datamanager.views.account.PersonalAccessTokenMain; import life.qbic.datamanager.views.general.Disclaimer; import life.qbic.datamanager.views.general.Main; -import life.qbic.datamanager.views.general.download.DownloadProvider; import life.qbic.datamanager.views.notifications.ErrorMessage; import life.qbic.datamanager.views.notifications.StyledNotification; import life.qbic.datamanager.views.projects.project.experiments.ExperimentMainLayout; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataURLContentProvider.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataURLContentProvider.java index 53dda15ba..0a44dddd3 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataURLContentProvider.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataURLContentProvider.java @@ -1,10 +1,8 @@ package life.qbic.datamanager.views.projects.project.rawdata; import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.List; -import life.qbic.datamanager.views.general.download.DownloadContentProvider; +import life.qbic.datamanager.download.DownloadContentProvider; import life.qbic.datamanager.views.general.download.TextFileBuilder; import life.qbic.datamanager.views.projects.project.rawdata.RawDataMain.RawDataURL; import life.qbic.projectmanagement.domain.model.experiment.Experiment; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/BatchDetailsComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/BatchDetailsComponent.java index 4ab44471c..31201eb70 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/BatchDetailsComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/BatchDetailsComponent.java @@ -78,7 +78,8 @@ public BatchDetailsComponent(@Autowired BatchInformationService batchInformation private void createTitleAndControls() { title.addClassName("title"); titleAndControls.addClassName("title-and-controls"); - Button registerButton = new Button("Register"); + Button registerButton = new Button("Register sample batch"); + registerButton.setClassName("primary"); registerButton.addClickListener(event -> fireEvent(new CreateBatchEvent(this, event.isFromClient()))); titleAndControls.add(title, registerButton); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java index 3be5cd306..891e2aadf 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java @@ -138,10 +138,10 @@ private static Grid createSampleGrid() { .setTooltipGenerator(preview -> preview.analyte().formatted()) .setAutoWidth(true) .setResizable(true); - sampleGrid.addColumn(SamplePreview::analysisMethod) + sampleGrid.addColumn(preview -> preview.analysisMethod().label()) .setHeader("Analysis to Perform") .setSortProperty("analysisMethod") - .setTooltipGenerator(SamplePreview::analysisMethod) + .setTooltipGenerator(samplePreview -> samplePreview.analysisMethod().label()) .setAutoWidth(true) .setResizable(true); sampleGrid.addColumn(SamplePreview::comment) diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java index 2c1a92d76..ffa78404a 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java @@ -3,6 +3,7 @@ import static java.util.Objects.requireNonNull; import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; @@ -16,34 +17,33 @@ import com.vaadin.flow.spring.annotation.UIScope; import jakarta.annotation.security.PermitAll; import java.io.Serial; -import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import life.qbic.application.commons.ApplicationException; +import life.qbic.datamanager.download.DownloadProvider; +import life.qbic.datamanager.templates.TemplateService; import life.qbic.datamanager.views.AppRoutes.Projects; import life.qbic.datamanager.views.Context; import life.qbic.datamanager.views.general.Disclaimer; import life.qbic.datamanager.views.general.DisclaimerConfirmedEvent; import life.qbic.datamanager.views.general.Main; -import life.qbic.datamanager.views.general.download.DownloadProvider; import life.qbic.datamanager.views.notifications.CancelConfirmationDialogFactory; import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; -import life.qbic.datamanager.views.notifications.StyledNotification; -import life.qbic.datamanager.views.notifications.SuccessMessage; import life.qbic.datamanager.views.projects.project.experiments.ExperimentMainLayout; import life.qbic.datamanager.views.projects.project.samples.BatchDetailsComponent.DeleteBatchEvent; import life.qbic.datamanager.views.projects.project.samples.BatchDetailsComponent.EditBatchEvent; import life.qbic.datamanager.views.projects.project.samples.download.SampleInformationXLSXProvider; -import life.qbic.datamanager.views.projects.project.samples.registration.batch.BatchRegistrationDialog; -import life.qbic.datamanager.views.projects.project.samples.registration.batch.BatchRegistrationDialog.ConfirmEvent; -import life.qbic.datamanager.views.projects.project.samples.registration.batch.EditBatchDialog; -import life.qbic.datamanager.views.projects.project.samples.registration.batch.SampleBatchInformationSpreadsheet; +import life.qbic.datamanager.views.projects.project.samples.registration.batch.EditSampleBatchDialog; +import life.qbic.datamanager.views.projects.project.samples.registration.batch.RegisterSampleBatchDialog; import life.qbic.datamanager.views.projects.project.samples.registration.batch.SampleBatchInformationSpreadsheet.SampleInfo; import life.qbic.logging.api.Logger; import life.qbic.logging.service.LoggerFactory; import life.qbic.projectmanagement.application.DeletionService; import life.qbic.projectmanagement.application.ProjectInformationService; +import life.qbic.projectmanagement.application.ProjectOverview; import life.qbic.projectmanagement.application.batch.BatchRegistrationService; import life.qbic.projectmanagement.application.batch.SampleUpdateRequest; import life.qbic.projectmanagement.application.batch.SampleUpdateRequest.SampleInformation; @@ -51,14 +51,14 @@ import life.qbic.projectmanagement.application.sample.SampleInformationService; import life.qbic.projectmanagement.application.sample.SamplePreview; import life.qbic.projectmanagement.application.sample.SampleRegistrationService; +import life.qbic.projectmanagement.application.sample.SampleRegistrationServiceV2; +import life.qbic.projectmanagement.application.sample.SampleValidationService; import life.qbic.projectmanagement.domain.model.batch.BatchId; import life.qbic.projectmanagement.domain.model.experiment.Experiment; import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; -import life.qbic.projectmanagement.domain.model.experiment.ExperimentalGroup; import life.qbic.projectmanagement.domain.model.project.Project; import life.qbic.projectmanagement.domain.model.project.ProjectId; import life.qbic.projectmanagement.domain.model.sample.Sample; -import life.qbic.projectmanagement.domain.model.sample.SampleId; import life.qbic.projectmanagement.domain.model.sample.SampleOrigin; import life.qbic.projectmanagement.domain.model.sample.SampleRegistrationRequest; import org.springframework.beans.factory.annotation.Autowired; @@ -83,8 +83,6 @@ public class SampleInformationMain extends Main implements BeforeEnterObserver { private static final long serialVersionUID = 3778218989387044758L; private static final Logger log = LoggerFactory.logger(SampleInformationMain.class); private final transient ExperimentInformationService experimentInformationService; - private final transient BatchRegistrationService batchRegistrationService; - private final transient SampleRegistrationService sampleRegistrationService; private final transient SampleInformationService sampleInformationService; private final transient DeletionService deletionService; private final transient SampleDetailsComponent sampleDetailsComponent; @@ -100,6 +98,9 @@ public class SampleInformationMain extends Main implements BeforeEnterObserver { private final ProjectInformationService projectInformationService; private final CancelConfirmationDialogFactory cancelConfirmationDialogFactory; private final MessageSourceNotificationFactory messageSourceNotificationFactory; + private final SampleValidationService sampleValidationService; + private final TemplateService templateService; + private final SampleRegistrationServiceV2 sampleRegistrationServiceV2; private transient Context context; public SampleInformationMain(@Autowired ExperimentInformationService experimentInformationService, @@ -111,13 +112,11 @@ public SampleInformationMain(@Autowired ExperimentInformationService experimentI @Autowired BatchDetailsComponent batchDetailsComponent, ProjectInformationService projectInformationService, CancelConfirmationDialogFactory cancelConfirmationDialogFactory, - MessageSourceNotificationFactory messageSourceNotificationFactory) { + MessageSourceNotificationFactory messageSourceNotificationFactory, + SampleValidationService sampleValidationService, + TemplateService templateService, SampleRegistrationServiceV2 sampleRegistrationServiceV2) { this.experimentInformationService = requireNonNull(experimentInformationService, "ExperimentInformationService cannot be null"); - this.batchRegistrationService = requireNonNull(batchRegistrationService, - "BatchRegistrationService cannot be null"); - this.sampleRegistrationService = requireNonNull(sampleRegistrationService, - "SampleRegistrationService cannot be null"); this.sampleInformationService = requireNonNull(sampleInformationService, "SampleInformationService cannot be null"); this.deletionService = requireNonNull(deletionService, @@ -131,6 +130,8 @@ public SampleInformationMain(@Autowired ExperimentInformationService experimentI "cancelConfirmationDialogFactory must not be null"); this.messageSourceNotificationFactory = requireNonNull(messageSourceNotificationFactory, "messageSourceNotificationFactory must not be null"); + this.sampleValidationService = sampleValidationService; + this.templateService = templateService; noGroupsDefinedDisclaimer = createNoGroupsDefinedDisclaimer(); noGroupsDefinedDisclaimer.setVisible(false); @@ -157,6 +158,7 @@ public SampleInformationMain(@Autowired ExperimentInformationService experimentI System.identityHashCode(batchDetailsComponent), sampleDetailsComponent.getClass().getSimpleName(), System.identityHashCode(sampleDetailsComponent))); + this.sampleRegistrationServiceV2 = sampleRegistrationServiceV2; } private static boolean noExperimentGroupsInExperiment(Experiment experiment) { @@ -181,7 +183,7 @@ private void initSearchFieldAndButtonBar() { searchField.setValueChangeMode(ValueChangeMode.LAZY); searchField.addValueChangeListener( event -> sampleDetailsComponent.onSearchFieldValueChanged((event.getValue()))); - Button metadataDownloadButton = new Button("Download Sample Metadata", + Button metadataDownloadButton = new Button("Download sample metadata", event -> downloadSampleMetadata()); Span buttonBar = new Span(metadataDownloadButton); buttonBar.addClassName("button-bar"); @@ -207,48 +209,71 @@ private void downloadSampleMetadata() { metadataDownload.trigger(); } + private static class HandledException extends RuntimeException { + + public HandledException(Throwable cause) { + super(cause); + } + } + private void onRegisterBatchClicked() { - Experiment experiment = context.experimentId() - .flatMap( - id -> experimentInformationService.find(context.projectId().orElseThrow().value(), id)) + ProjectId projectId = context.projectId().orElseThrow(); + ExperimentId experimentId = context.experimentId().orElseThrow(); + + Experiment experiment = experimentInformationService.find(projectId.value(), experimentId) .orElseThrow(); + if (experiment.getExperimentalGroups().isEmpty()) { return; } - BatchRegistrationDialog dialog = new BatchRegistrationDialog( - experiment.getName(), new ArrayList<>(experiment.getSpecies()), - new ArrayList<>(experiment.getSpecimens()), new ArrayList<>(experiment.getAnalytes()), - experiment.getExperimentalGroups()); - dialog.addCancelListener(cancelEvent -> showCancelConfirmationDialog(dialog)); - dialog.setEscAction(() -> showCancelConfirmationDialog(dialog)); - dialog.addConfirmListener(this::registerBatch); - dialog.open(); + ProjectOverview projectOverview = projectInformationService.findOverview(projectId) + .orElseThrow(); + RegisterSampleBatchDialog registerSampleBatchDialog = new RegisterSampleBatchDialog( + sampleValidationService, templateService, experimentId.value(), + projectId.value(), projectOverview.projectCode()); + registerSampleBatchDialog.addConfirmListener(event -> { + event.getSource().taskInProgress("Register the sample batch metadata", + "It may take some time for the registration task to complete."); + UI ui = event.getSource().getUI().orElseThrow(); + CompletableFuture registrationTask = sampleRegistrationServiceV2.registerSamples( + event.validatedSampleMetadata(), + projectId, event.batchName(), false) + .orTimeout(5, TimeUnit.MINUTES); + try { + registrationTask + .exceptionally(e -> { + ui.access(() -> { + //this needs to come before all the success events + event.getSource().taskFailed("", ""); //todo label and description s + displayRegistrationFailure(); + }); + throw new HandledException(e); + }) + .thenRun(() -> ui.access(this::setBatchAndSampleInformation)) + .thenRun(() -> ui.access(() -> event.getSource().taskSucceeded("", ""))) + .thenRun(() -> displayRegistrationSuccess(event.batchName())) + .exceptionally(e -> { + //we need to make sure we do not swallow exceptions but still stay in the exceptional state. + throw new HandledException(e); //we need the future to complete exceptionally + }); + } catch (HandledException e) { + // we only log the exception as the user was presented with the error already and nothing we can do here. + log.error(e.getMessage(), e); + } + }); + registerSampleBatchDialog.addCancelListener( + event -> showCancelConfirmationDialog(event.getSource())); + registerSampleBatchDialog.setEscAction( + () -> showCancelConfirmationDialog(registerSampleBatchDialog)); + registerSampleBatchDialog.open(); } - private void showCancelConfirmationDialog(BatchRegistrationDialog dialog) { + private void showCancelConfirmationDialog(RegisterSampleBatchDialog dialog) { cancelConfirmationDialogFactory.cancelConfirmationDialog(it -> dialog.close(), "sample-batch.register", getLocale()) .open(); } - private void registerBatch(ConfirmEvent confirmEvent) { - String batchLabel = confirmEvent.getData().batchName(); - List samples = confirmEvent.getData().samples(); - List sampleRegistrationRequests = batchRegistrationService.registerBatch( - batchLabel, false, - context.projectId().orElseThrow()) - .map(batchId -> generateSampleRequestsFromSampleInfo(batchId, samples)) - .onError(responseCode -> displayRegistrationFailure()) - .valueOrElseThrow(() -> - new ApplicationException("Could not create sample registration requests")); - sampleRegistrationService.registerSamples(sampleRegistrationRequests, - context.projectId().orElseThrow()) - .onError(responseCode -> displayRegistrationFailure()) - .onValue(ignored -> fireEvent(new BatchRegisteredEvent(this, false))) - .onValue(ignored -> confirmEvent.getSource().close()) - .onValue(batchId -> displayRegistrationSuccess(batchLabel)) - .onValue(ignored -> setBatchAndSampleInformation()); - } private List generateSampleRequestsFromSampleInfo(BatchId batchId, List sampleInfos) { @@ -313,10 +338,9 @@ private void routeToExperimentalGroupCreation(ComponentEvent componentEvent, } } - private void displayUpdateSuccess() { - SuccessMessage successMessage = new SuccessMessage("Batch update succeeded.", ""); - StyledNotification notification = new StyledNotification(successMessage); - notification.open(); + private void displayUpdateSuccess(String batchName) { + messageSourceNotificationFactory.toast("sample-batch.", new String[]{batchName}, getLocale()) + .open(); } private void displayDeletionSuccess(String batchLabel) { @@ -333,6 +357,13 @@ private void displayRegistrationSuccess(String batchLabel) { } + private void displayUpdateFailure() { + messageSourceNotificationFactory.dialog("sample-batch.update.failure", + MessageSourceNotificationFactory.EMPTY_PARAMETERS, + getLocale()) + .open(); + } + private void displayRegistrationFailure() { messageSourceNotificationFactory.dialog( "sample-batch.register.failure", @@ -341,75 +372,72 @@ MessageSourceNotificationFactory.EMPTY_PARAMETERS, getLocale()) } private void onEditBatchClicked(EditBatchEvent editBatchEvent) { - Experiment experiment = context.experimentId() - .flatMap( - id -> experimentInformationService.find(context.projectId().orElseThrow().value(), id)) + ProjectId projectId = context.projectId().orElseThrow(); + ExperimentId experimentId = context.experimentId().orElseThrow(); + + Experiment experiment = experimentInformationService.find(projectId.value(), experimentId) .orElseThrow(); - List samples = sampleInformationService.retrieveSamplesForBatch( - editBatchEvent.batchPreview().batchId()).stream().toList(); - var experimentalGroups = experimentInformationService.experimentalGroupsFor( - context.projectId().orElseThrow().value(), - context.experimentId().orElseThrow()); - // need to create mutable list to order samples - List sampleInfos = new ArrayList<>( - samples.stream() - .map(sample -> convertSampleToSampleInfo(sample, experimentalGroups)).toList()); - sampleInfos.sort(Comparator.comparing(o -> o.getSampleCode().code())); - EditBatchDialog editBatchDialog = new EditBatchDialog(experiment.getName(), - experiment.getSpecies().stream().toList(), experiment.getSpecimens().stream().toList(), - experiment.getAnalytes().stream().toList(), experiment.getExperimentalGroups(), - editBatchEvent.batchPreview() - .batchId(), editBatchEvent.batchPreview().batchLabel(), sampleInfos, - this::isSampleRemovable); - editBatchDialog.addCancelListener( - cancelEvent -> showCancelConfirmationDialog(editBatchDialog)); - editBatchDialog.setEscAction(escEvent -> showCancelConfirmationDialog(editBatchDialog)); - editBatchDialog.addConfirmListener(this::editBatch); - editBatchDialog.open(); + + if (experiment.getExperimentalGroups().isEmpty()) { + return; + } + ProjectOverview projectOverview = projectInformationService.findOverview(projectId) + .orElseThrow(); + BatchId batchId = editBatchEvent.batchPreview().batchId(); + String batchLabel = editBatchEvent.batchPreview().batchLabel(); + var editSampleBatchDialog = new EditSampleBatchDialog( + sampleValidationService, templateService, batchId, batchLabel, experimentId.value(), + projectId.value(), projectOverview.projectCode()); + editSampleBatchDialog.addConfirmListener(event -> { + event.getSource().taskInProgress("Edit the sample batch metadata", + "It may take some time for the editing to complete."); + UI ui = event.getSource().getUI().orElseThrow(); + CompletableFuture editTask = sampleRegistrationServiceV2.updateSamples( + event.validatedSampleMetadata(), + projectId, + batchId, + event.batchName(), + false) + .orTimeout(5, TimeUnit.MINUTES); + try { + editTask + .exceptionally(e -> { + ui.access(() -> { + //this needs to come before all the success events + event.getSource().taskFailed("", ""); //todo label and description s + displayUpdateFailure(); + }); + throw new HandledException(e); + }) + .thenRun(() -> ui.access(this::setBatchAndSampleInformation)) + .thenRun(() -> ui.access( + () -> event.getSource().taskSucceeded("", ""))) //todo label and description + .thenRun(() -> displayUpdateSuccess(event.batchName())) + .exceptionally(e -> { + //we need to make sure we do not swallow exceptions but still stay in the exceptional state. + throw new HandledException(e); //we need the future to complete exceptionally + }); + } catch (HandledException e) { + // we only log the exception as the user was presented with the error already and nothing we can do here. + log.error(e.getMessage(), e); + } + }); + editSampleBatchDialog.addCancelListener( + event -> showCancelConfirmationDialog(event.getSource())); + editSampleBatchDialog.setEscAction( + () -> showCancelConfirmationDialog(editSampleBatchDialog)); + editSampleBatchDialog.open(); } - private void showCancelConfirmationDialog(EditBatchDialog editBatchDialog) { + private void showCancelConfirmationDialog(EditSampleBatchDialog editBatchDialog) { cancelConfirmationDialogFactory .cancelConfirmationDialog( - it2 -> editBatchDialog.close(), + it -> editBatchDialog.close(), "sample-batch.edit", getLocale()) .open(); } - private boolean isSampleRemovable(SampleId sampleId) { - return deletionService.isSampleRemovable(sampleId); - } - - private SampleBatchInformationSpreadsheet.SampleInfo convertSampleToSampleInfo(Sample sample, - Collection experimentalGroups) { - ExperimentalGroup experimentalGroup = experimentalGroups.stream() - .filter(expGrp -> expGrp.id() == sample.experimentalGroupId()) - .findFirst().orElseThrow(); - /*We currently allow replicates independent of experimental groups which is why we have to parse all replicates */ - return SampleBatchInformationSpreadsheet.SampleInfo.create(sample.sampleId(), - sample.sampleCode(), sample.analysisMethod(), - sample.label(), sample.biologicalReplicate(), experimentalGroup, sample.sampleOrigin() - .getSpecies(), sample.sampleOrigin().getSpecimen(), sample.sampleOrigin().getAnalyte(), - sample.comment().orElse("")); - } - - private void editBatch(EditBatchDialog.ConfirmEvent confirmEvent) { - boolean isPilot = false; - Collection createdSamples = generateSampleRequestsFromSampleInfo( - confirmEvent.getData().batchId(), confirmEvent.getData().addedSamples()); - Collection editedSamples = confirmEvent.getData().changedSamples().stream() - .map(this::generateSampleUpdateRequestFromSampleInfo).toList(); - Collection deletedSamples = confirmEvent.getData().removedSamples().stream() - .map(SampleInfo::getSampleId).toList(); - var result = batchRegistrationService.editBatch(confirmEvent.getData().batchId(), - confirmEvent.getData().batchName(), isPilot, createdSamples, editedSamples, - deletedSamples, context.projectId().orElseThrow()); - result.onValue(ignored -> confirmEvent.getSource().close()); - result.onValue(batchId -> displayUpdateSuccess()); - result.onValue(ignored -> setBatchAndSampleInformation()); - } - private void deleteBatch(DeleteBatchEvent deleteBatchEvent) { deletionService.deleteBatch(context.projectId().orElseThrow(), deleteBatchEvent.batchId()); @@ -510,19 +538,4 @@ private void reloadBatchInformation() { private void reloadSampleInformation() { sampleDetailsComponent.setContext(context); } - - public static class BatchRegisteredEvent extends ComponentEvent { - - /** - * Creates a new event using the given source and indicator whether the event originated from - * the client side or the server side. - * - * @param source the source component - * @param fromClient true if the event originated from the client - * side, false otherwise - */ - public BatchRegisteredEvent(SampleInformationMain source, boolean fromClient) { - super(source, fromClient); - } - } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleMetadataContentProvider.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleMetadataContentProvider.java index 10ca48ced..e04933bec 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleMetadataContentProvider.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleMetadataContentProvider.java @@ -5,7 +5,7 @@ import java.util.Comparator; import java.util.List; import java.util.Optional; -import life.qbic.datamanager.views.general.download.DownloadContentProvider; +import life.qbic.datamanager.download.DownloadContentProvider; import life.qbic.datamanager.views.general.download.TSVBuilder; import life.qbic.projectmanagement.application.sample.SamplePreview; import life.qbic.projectmanagement.domain.model.experiment.Condition; @@ -37,7 +37,8 @@ public byte[] getContent() { tsvBuilder.addColumn("Species", sample -> sample.species().getLabel()); tsvBuilder.addColumn("Specimen", sample -> sample.specimen().getLabel()); tsvBuilder.addColumn("Analyte", sample -> sample.analyte().getLabel()); - tsvBuilder.addColumn("Analysis to Perform", SamplePreview::analysisMethod); + tsvBuilder.addColumn("Analysis to Perform", + samplePreview -> samplePreview.analysisMethod().abbreviation()); tsvBuilder.addColumn("Comment", SamplePreview::comment); SamplePreview firstSample = samples.stream().findFirst().get(); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/download/SampleInformationXLSXProvider.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/download/SampleInformationXLSXProvider.java index f65f46d74..76235be51 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/download/SampleInformationXLSXProvider.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/download/SampleInformationXLSXProvider.java @@ -14,7 +14,7 @@ import java.util.Set; import life.qbic.application.commons.ApplicationException; import life.qbic.application.commons.ApplicationException.ErrorCode; -import life.qbic.datamanager.views.general.download.DownloadContentProvider; +import life.qbic.datamanager.download.DownloadContentProvider; import life.qbic.logging.api.Logger; import life.qbic.projectmanagement.application.sample.SamplePreview; import life.qbic.projectmanagement.domain.model.experiment.VariableLevel; @@ -137,7 +137,7 @@ private void createSampleInfoEntry(SamplePreview sample, Row sampleRow, analyteCol.setCellValue(sample.analyte().getLabel()); var analysisCol = sampleRow.createCell(SamplePreviewColumn.ANALYSIS.column()); - analysisCol.setCellValue(sample.analysisMethod()); + analysisCol.setCellValue(sample.analysisMethod().abbreviation()); var commentCol = sampleRow.createCell(SamplePreviewColumn.COMMENT.column()); commentCol.setCellValue(sample.comment()); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/BatchRegistrationDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/BatchRegistrationDialog.java index 126fe71c1..c032b76d9 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/BatchRegistrationDialog.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/BatchRegistrationDialog.java @@ -19,7 +19,9 @@ /** * A dialog used for sample batch registration. + * @deprecated this is replaced by {@link RegisterSampleBatchDialog} */ +@Deprecated(forRemoval = true, since = "1.4.0") public class BatchRegistrationDialog extends DialogWindow { private final TextField batchNameField; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditBatchDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditBatchDialog.java index a21f037c0..e7cc3308a 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditBatchDialog.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditBatchDialog.java @@ -25,7 +25,9 @@ /** * A dialog used for editing sample and batch information within an experiment + * @Deprecated replaced by {@link EditSampleBatchDialog} */ +@Deprecated(forRemoval = true, since = "1.4.0") public class EditBatchDialog extends DialogWindow { private final SampleBatchInformationSpreadsheet spreadsheet; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditSampleBatchDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditSampleBatchDialog.java new file mode 100644 index 000000000..0bbfaf5d9 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditSampleBatchDialog.java @@ -0,0 +1,489 @@ +package life.qbic.datamanager.views.projects.project.samples.registration.batch; + + +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.progressbar.ProgressBar; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.shared.Registration; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import life.qbic.datamanager.download.DownloadContentProvider.XLSXDownloadContentProvider; +import life.qbic.datamanager.download.DownloadProvider; +import life.qbic.datamanager.parser.ParsingResult; +import life.qbic.datamanager.parser.sample.SampleInformationExtractor; +import life.qbic.datamanager.parser.sample.SampleInformationExtractor.SampleInformationForExistingSample; +import life.qbic.datamanager.parser.xlsx.XLSXParser; +import life.qbic.datamanager.templates.TemplateService; +import life.qbic.datamanager.views.general.WizardDialogWindow; +import life.qbic.datamanager.views.general.upload.UploadWithDisplay; +import life.qbic.datamanager.views.general.upload.UploadWithDisplay.FileType; +import life.qbic.datamanager.views.general.upload.UploadWithDisplay.SucceededEvent; +import life.qbic.datamanager.views.general.upload.UploadWithDisplay.UploadedData; +import life.qbic.logging.api.Logger; +import life.qbic.logging.service.LoggerFactory; +import life.qbic.projectmanagement.application.ValidationResult; +import life.qbic.projectmanagement.application.ValidationResultWithPayload; +import life.qbic.projectmanagement.application.sample.SampleMetadata; +import life.qbic.projectmanagement.application.sample.SampleValidationService; +import life.qbic.projectmanagement.domain.model.batch.BatchId; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +/** + * A dialog used for editing sample and batch information. + * + * @since 1.4.0 + */ +public class EditSampleBatchDialog extends WizardDialogWindow { + + private static final Logger log = LoggerFactory.logger(EditSampleBatchDialog.class); + + private final List validatedSampleMetadata; + private final TextField batchNameField; + private final Div initialView; + private final Div inProgressView; + private final Div failedView; + private final Div succeededView; + private static final int MAX_FILE_SIZE = 25 * 1024 * 1024; + + + public EditSampleBatchDialog(SampleValidationService sampleValidationService, + TemplateService templateService, + BatchId batchId, + String batchName, + String experimentId, + String projectId, + String projectCode) { + + setHeaderTitle("Edit Sample Batch"); + setConfirmButtonLabel("Edit Batch"); + initialView = new Div(); + initialView.addClassName("initial-view"); + inProgressView = new Div(); + inProgressView.addClassName("in-progress-view"); + failedView = new Div(); + failedView.addClassName("failed-view"); + succeededView = new Div(); + succeededView.addClassName("succeeded-view"); + + addClassName("edit-samples-dialog"); + batchNameField = new TextField("Batch name"); + batchNameField.setRequired(true); + batchNameField.setValue(batchName); + batchNameField.setPlaceholder("Please enter a name for your batch"); + batchNameField.addClassName("batch-name-field"); + + Div downloadMetadataSection = setupDownloadMetadataSection(templateService, batchId, + experimentId, + projectId, projectCode); + + + + setHeaderTitle("Edit Sample Batch"); + validatedSampleMetadata = new ArrayList<>(); + + UploadWithDisplay uploadWithDisplay = new UploadWithDisplay( + MAX_FILE_SIZE, new FileType[]{ + new FileType(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + }); + uploadWithDisplay.addFailureListener(uploadFailed -> { + /* display of the error is handled by the uploadWithDisplay component. So nothing to do here.*/ + }); + uploadWithDisplay.addSuccessListener( + uploadSucceeded -> onUploadSucceeded(sampleValidationService, experimentId, projectId, + uploadSucceeded) + ); + uploadWithDisplay.addRemovedListener(it -> setValidatedSampleMetadata(List.of())); + + initialView.add(batchNameField, downloadMetadataSection, uploadWithDisplay); + initialView.setVisible(true); + inProgressView.setVisible(false); + failedView.setVisible(false); + succeededView.setVisible(false); + add(initialView, inProgressView, failedView, succeededView); + } + + static class InvalidUploadDisplay extends Div { + + public InvalidUploadDisplay(String fileName, List failureReasons) { + addClassName("uploaded-item"); + var fileIcon = VaadinIcon.FILE.create(); + fileIcon.addClassName("file-icon"); + Span fileNameLabel = new Span(fileIcon, new Span(fileName)); + fileNameLabel.addClassName("file-name"); + Div validationBox = new Div(); + validationBox.addClassName("validation-display-box"); + var box = new Div(); + var failuresTitle = new Span("Invalid sample metadata"); + var errorIcon = VaadinIcon.CLOSE_CIRCLE.create(); + errorIcon.addClassName("error"); + var header = new Span(errorIcon, failuresTitle); + header.addClassName("header"); + var instruction = new Span( + "Please correct the entries in the uploaded file and re-upload the file."); + instruction.addClassName("secondary"); + Div validationDetails = new Div(); + Map frequencyMap = failureReasons.stream() + .distinct() + .collect(Collectors.toMap( + Function.identity(), + v -> Collections.frequency(failureReasons, v) + )); + frequencyMap.forEach( + (key, frequency) -> validationDetails.add(new Div(frequency + " times: " + key))); + box.add(header, validationDetails, instruction); + validationBox.add(box); + add(fileNameLabel, validationBox); + } + } + + static class ValidUploadDisplay extends Div { + + private ValidUploadDisplay(String fileName, int count) { + addClassName("uploaded-item"); + var fileIcon = VaadinIcon.FILE.create(); + fileIcon.addClassName("file-icon"); + Span fileNameLabel = new Span(fileIcon, new Span(fileName)); + fileNameLabel.addClassName("file-name"); + Div validationBox = new Div(); + validationBox.addClassName("validation-display-box"); + var box = new Div(); + var approvedTitle = new Span("Your data has been approved"); + var validIcon = VaadinIcon.CHECK_CIRCLE_O.create(); + validIcon.addClassName("success"); + var header = new Span(validIcon, approvedTitle); + header.addClassName("header"); + var instruction = new Span("Please click Register to register your samples"); + instruction.addClassName("secondary"); + Div validationDetails = new Div(); + var approvedSamples = new Span("%d samples".formatted(count)); + approvedSamples.addClassName("bold"); + validationDetails.add(new Span("Sample data for "), approvedSamples, + new Span(" is now ready to be registered.")); + box.add(header, validationDetails, instruction); + validationBox.add(box); + add(fileNameLabel, validationBox); + } + } + + private static class InProgressDisplay extends Div { + + private InProgressDisplay(String fileName) { + addClassName("uploaded-item"); + var fileIcon = VaadinIcon.FILE.create(); + fileIcon.addClassName("file-icon"); + Span fileNameLabel = new Span(fileIcon, new Span(fileName)); + fileNameLabel.addClassName("file-name"); + ProgressBar progressBar = new ProgressBar(); + progressBar.setIndeterminate(true); + add(fileNameLabel, new Div("Validating file..."), progressBar); + } + } + + private static InvalidUploadDisplay invalidDisplay(String fileName, + List validationResults) { + List failureReasons = validationResults.stream() + .flatMap(res -> res.failures().stream()).toList(); + return new InvalidUploadDisplay(fileName, failureReasons); + } + + + private void onUploadSucceeded(SampleValidationService sampleValidationService, + String experimentId, String projectId, SucceededEvent uploadSucceeded) { + UploadWithDisplay component = uploadSucceeded.getSource(); + UI ui = component.getUI().orElseThrow(); + UploadedData uploadedData = component.getUploadedData().orElseThrow(); + + InProgressDisplay uploadProgressDisplay = new InProgressDisplay(uploadedData.fileName()); + component.setDisplay(uploadProgressDisplay); + + List sampleInformationForExistingSamples = extractSampleInformationForExistingSamples( + uploadedData); + + List>> validations = new ArrayList<>(); + for (SampleInformationForExistingSample sampleInformationForExistingSample : sampleInformationForExistingSamples) { + CompletableFuture> validation = sampleValidationService.validateExistingSampleAsync( + sampleInformationForExistingSample.sampleCode(), + sampleInformationForExistingSample.sampleName(), + sampleInformationForExistingSample.biologicalReplicate(), + sampleInformationForExistingSample.condition(), + sampleInformationForExistingSample.species(), + sampleInformationForExistingSample.specimen(), + sampleInformationForExistingSample.analyte(), + sampleInformationForExistingSample.analysisMethod(), + sampleInformationForExistingSample.comment(), + experimentId, + projectId + ).orTimeout(1, TimeUnit.MINUTES); + validations.add(validation); + } + var validationTasks = CompletableFuture + //allOf makes sure exceptional state is transferred to outer completable future. + .allOf(validations.toArray(new CompletableFuture[0])) + .thenApply(v -> validations.stream() + .map(CompletableFuture::join) + .toList()) + .orTimeout(5, TimeUnit.MINUTES); + + validationTasks + .thenAccept(validationResults -> { + + List> failedValidations = validationResults.stream() + .filter(validation -> validation.validationResult().containsFailures()) + .toList(); + List> succeededValidations = validationResults.stream() + .filter(validation -> validation.validationResult().allPassed()) + .toList(); + + if (!failedValidations.isEmpty()) { + ui.access(() -> component.setDisplay(invalidDisplay(uploadedData.fileName(), + failedValidations.stream().map(ValidationResultWithPayload::validationResult) + .toList()))); + setValidatedSampleMetadata(List.of()); + return; + } + if (!succeededValidations.isEmpty()) { + ui.access(() -> component + .setDisplay(new ValidUploadDisplay(uploadedData.fileName(), + succeededValidations.size()))); + setValidatedSampleMetadata( + succeededValidations.stream().map(ValidationResultWithPayload::payload).toList()); + } + }) + .exceptionally(e -> { + RuntimeException runtimeException = new RuntimeException( + "At least one validation task could not complete.", e); + log.error("Could not complete validation. Please try again.", runtimeException); + InvalidUploadDisplay invalidUploadDisplay = invalidDisplay(uploadedData.fileName(), + List.of( + ValidationResult.withFailures( + List.of("Could not complete validation. Please try again.")))); + ui.access(() -> component.setDisplay(invalidUploadDisplay)); + throw runtimeException; + } + ); + } + + private void setValidatedSampleMetadata(List metadata) { + this.validatedSampleMetadata.clear(); + this.validatedSampleMetadata.addAll(metadata); + } + + private List extractSampleInformationForExistingSamples( + UploadedData uploadedData) { + ParsingResult parsingResult = XLSXParser.create().parse(uploadedData.inputStream()); + return new SampleInformationExtractor() + .extractInformationForExistingSamples(parsingResult); + + } + + private Div setupDownloadMetadataSection(TemplateService templateService, + BatchId batchId, + String experimentId, + String projectId, String projectCode) { + Button downloadTemplate = new Button("Download metadata template"); + downloadTemplate.addClassName("download-metadata-button"); + downloadTemplate.addClickListener(buttonClickEvent -> { + try (XSSFWorkbook workbook = templateService.sampleBatchUpdateXLSXTemplate( + batchId, + projectId, + experimentId)) { + var downloadProvider = new DownloadProvider( + new XLSXDownloadContentProvider(projectCode + "_edit_batch_template.xlsx", workbook)); + add(downloadProvider); + downloadProvider.trigger(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + Div text = new Div(); + text.addClassName("download-metadata-text"); + text.setText( + "Please download the metadata template, adapt the sample properties and upload the metadata sheet below to edit the sample batch."); + Div downloadMetadataSection = new Div(); + downloadMetadataSection.addClassName("download-metadata"); + Span sectionTitle = new Span("Download metadata template"); + sectionTitle.addClassName("section-title"); + sectionTitle.addClassName("download-metadata-section-title"); + Div sectionContent = new Div(); + sectionContent.addClassName("download-metadata-section-content"); + sectionContent.add(text, downloadTemplate); + downloadMetadataSection.add(sectionTitle, sectionContent); + return downloadMetadataSection; + } + + public Registration addConfirmListener(ComponentEventListener listener) { + return addListener(ConfirmEvent.class, listener); + } + + public Registration addCancelListener(ComponentEventListener listener) { + return addListener(CancelEvent.class, listener); + } + + @Override + public void close() { + validatedSampleMetadata.clear(); + super.close(); + } + + @Override + protected void onConfirmClicked(ClickEvent