diff --git a/src/main/java/org/breedinginsight/api/model/v1/request/SharedOntologyProgramRequest.java b/src/main/java/org/breedinginsight/api/model/v1/request/SharedOntologyProgramRequest.java new file mode 100644 index 000000000..1f745ef49 --- /dev/null +++ b/src/main/java/org/breedinginsight/api/model/v1/request/SharedOntologyProgramRequest.java @@ -0,0 +1,20 @@ +package org.breedinginsight.api.model.v1.request; + +import io.micronaut.core.annotation.Introspected; +import lombok.*; + +import javax.validation.constraints.NotBlank; +import java.util.UUID; + +@Getter +@Setter +@Builder +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Introspected +public class SharedOntologyProgramRequest { + @NotBlank + private UUID programId; + private String programName; +} diff --git a/src/main/java/org/breedinginsight/api/v1/controller/OntologyController.java b/src/main/java/org/breedinginsight/api/v1/controller/OntologyController.java new file mode 100644 index 000000000..cd02ef6ea --- /dev/null +++ b/src/main/java/org/breedinginsight/api/v1/controller/OntologyController.java @@ -0,0 +1,138 @@ +package org.breedinginsight.api.v1.controller; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.*; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; +import lombok.extern.slf4j.Slf4j; +import org.breedinginsight.api.auth.ProgramSecured; +import org.breedinginsight.api.auth.ProgramSecuredRole; +import org.breedinginsight.api.auth.ProgramSecuredRoleGroup; +import org.breedinginsight.api.auth.SecurityService; +import org.breedinginsight.api.model.v1.request.SharedOntologyProgramRequest; +import org.breedinginsight.api.model.v1.response.DataResponse; +import org.breedinginsight.api.model.v1.response.Response; +import org.breedinginsight.api.model.v1.response.metadata.Metadata; +import org.breedinginsight.api.model.v1.response.metadata.Pagination; +import org.breedinginsight.api.model.v1.response.metadata.Status; +import org.breedinginsight.api.model.v1.response.metadata.StatusCode; +import org.breedinginsight.api.v1.controller.metadata.AddMetadata; +import org.breedinginsight.model.SharedProgram; +import org.breedinginsight.model.Trait; +import org.breedinginsight.services.OntologyService; +import org.breedinginsight.services.exceptions.DoesNotExistException; +import org.breedinginsight.services.exceptions.UnprocessableEntityException; +import org.breedinginsight.services.exceptions.ValidatorException; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Controller("/${micronaut.bi.api.version}") +public class OntologyController { + + private SecurityService securityService; + private OntologyService ontologyService; + + @Inject + public OntologyController(SecurityService securityService, OntologyService ontologyService) { + this.securityService = securityService; + this.ontologyService = ontologyService; + } + + /** + * Returns all available programs available to share programs ontology with. Includes programs + * currently being shared with and their editable (unshareable) status. + * + * @param programId + * @param shared + * @return + * { + * data: [ + * { + * programName: string, -- Program name + * programId: UUID, -- Program ID + * shared: boolean, + * accepted: boolean || null, -- null if shared = false + * editable: boolean || null -- null if shared = false + * } + * ] + * } + */ + @Get("/programs/{programId}/ontology/shared/programs{?shared}") + @Produces(MediaType.APPLICATION_JSON) + @AddMetadata + @ProgramSecured(roles = {ProgramSecuredRole.BREEDER}) + public HttpResponse>> getAvailablePrograms( + @PathVariable UUID programId, @QueryValue(defaultValue = "false") Boolean shared) { + List sharedPrograms = ontologyService.getSharedOntology(programId, shared); + List metadataStatus = new ArrayList<>(); + metadataStatus.add(new Status(StatusCode.INFO, "Successful Query")); + Pagination pagination = new Pagination(sharedPrograms.size(), 1, 1, 0); + Metadata metadata = new Metadata(pagination, metadataStatus); + Response> response = new Response(metadata, new DataResponse<>(sharedPrograms)); + return HttpResponse.ok(response); + } + + /** + * Accepts a list of programs to shared the ontology with. + * @param programId + * @return List of programs successfully shared to with acceptable status + * { + * data: [ + * { + * programName: string, -- Program name + * programId: UUID, -- Required. Program ID + * } + * ] + * } + */ + @Post("/programs/{programId}/ontology/shared/programs") + @Produces(MediaType.APPLICATION_JSON) + @ProgramSecured(roles = {ProgramSecuredRole.BREEDER}) + public HttpResponse>> shareOntology( + @PathVariable UUID programId, @Body List request) { + try { + List sharedPrograms = ontologyService.shareOntology(programId, securityService.getUser(), request); + List metadataStatus = new ArrayList<>(); + metadataStatus.add(new Status(StatusCode.INFO, "Successful Creation")); + Pagination pagination = new Pagination(sharedPrograms.size(), 1, 1, 0); + Metadata metadata = new Metadata(pagination, metadataStatus); + Response> response = new Response(metadata, new DataResponse<>(sharedPrograms)); + return HttpResponse.ok(response); + } catch (ValidatorException e) { + log.error("Validation errors", e); + HttpResponse response = HttpResponse.status(HttpStatus.UNPROCESSABLE_ENTITY).body(e.getErrors()); + return response; + } catch (UnprocessableEntityException e) { + return HttpResponse.status(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage()); + } + } + + /** + * Revokes access to shared ontology from a program. If program is not currently shared with + * will return 404. + * + * @param programId + * @param sharedProgramId + * @return + */ + @Delete("/programs/{programId}/ontology/shared/programs/{sharedProgramId}") + @Produces(MediaType.APPLICATION_JSON) + @ProgramSecured(roles = {ProgramSecuredRole.BREEDER}) + public HttpResponse>> revokeOntology( + @PathVariable UUID programId, @PathVariable UUID sharedProgramId) { + try { + ontologyService.revokeOntology(programId, sharedProgramId); + return HttpResponse.ok(); + } catch (UnprocessableEntityException e) { + return HttpResponse.status(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage()); + } catch (DoesNotExistException e) { + return HttpResponse.status(HttpStatus.NOT_FOUND, e.getMessage()); + } + } +} diff --git a/src/main/java/org/breedinginsight/daos/ProgramDAO.java b/src/main/java/org/breedinginsight/daos/ProgramDAO.java index c7835fd2d..0cf0845f6 100644 --- a/src/main/java/org/breedinginsight/daos/ProgramDAO.java +++ b/src/main/java/org/breedinginsight/daos/ProgramDAO.java @@ -36,9 +36,7 @@ import org.breedinginsight.dao.db.tables.BiUserTable; import org.breedinginsight.dao.db.tables.daos.ProgramDao; import org.breedinginsight.dao.db.tables.pojos.ProgramEntity; -import org.breedinginsight.model.Program; -import org.breedinginsight.model.ProgramBrAPIEndpoints; -import org.breedinginsight.model.Species; +import org.breedinginsight.model.*; import org.breedinginsight.model.User; import org.breedinginsight.services.brapi.BrAPIClientProvider; import org.breedinginsight.services.brapi.BrAPIClientType; @@ -76,7 +74,7 @@ public class ProgramDAO extends ProgramDao { private String referenceSource; private Duration requestTimeout; - private final static String SYSTEM_DEFAULT = "System Default"; + private final static String SYSTEM_DEFAULT = BrAPIConstants.SYSTEM_DEFAULT.getValue(); @Inject public ProgramDAO(Configuration config, DSLContext dsl, BrAPIProvider brAPIProvider, BrAPIClientProvider brAPIClientProvider, @@ -364,6 +362,5 @@ private Duration getRequestTimeout() { return Duration.of(5, ChronoUnit.MINUTES); } - } diff --git a/src/main/java/org/breedinginsight/daos/ProgramOntologyDAO.java b/src/main/java/org/breedinginsight/daos/ProgramOntologyDAO.java index 16a48732a..2068f34cf 100644 --- a/src/main/java/org/breedinginsight/daos/ProgramOntologyDAO.java +++ b/src/main/java/org/breedinginsight/daos/ProgramOntologyDAO.java @@ -18,18 +18,55 @@ package org.breedinginsight.daos; import org.breedinginsight.dao.db.tables.daos.ProgramOntologyDao; +import org.breedinginsight.dao.db.tables.daos.ProgramSharedOntologyDao; +import org.breedinginsight.dao.db.tables.pojos.ProgramSharedOntologyEntity; import org.jooq.Configuration; import org.jooq.DSLContext; import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; +@Singleton public class ProgramOntologyDAO extends ProgramOntologyDao { private DSLContext dsl; + private ProgramSharedOntologyDao programSharedOntologyDao; @Inject - public ProgramOntologyDAO(Configuration config, DSLContext dsl) { + public ProgramOntologyDAO(Configuration config, DSLContext dsl, ProgramSharedOntologyDao programSharedOntologyDao) { super(config); this.dsl = dsl; + this.programSharedOntologyDao = programSharedOntologyDao; + } + + public void createSharedOntologies(List shareRecords) { + programSharedOntologyDao.insert(shareRecords); + } + + public List getSharedOntologies(UUID programId) { + return programSharedOntologyDao.fetchByProgramId(programId); + } + + public Optional getSharedOntologyById(UUID programId, UUID sharedProgramId) { + List sharedOntologies = getSharedOntologies(programId).stream() + .filter(programSharedOntologyEntity -> programSharedOntologyEntity.getSharedProgramId().equals(sharedProgramId)) + .collect(Collectors.toList()); + return sharedOntologies.size() > 0 ? Optional.of(sharedOntologies.get(0)) : Optional.empty(); + } + + public void revokeSharedOntology(ProgramSharedOntologyEntity sharedOntology) { + programSharedOntologyDao.delete(sharedOntology); + } + + public boolean programSubscribedSharedOntology(UUID programId) { + List shareRecords = programSharedOntologyDao.fetchBySharedProgramId(programId); + return !shareRecords.stream() + .filter(shareRecord -> shareRecord.getActive()) + .collect(Collectors.toList()) + .isEmpty(); } } diff --git a/src/main/java/org/breedinginsight/model/BrAPIConstants.java b/src/main/java/org/breedinginsight/model/BrAPIConstants.java new file mode 100644 index 000000000..47ba71804 --- /dev/null +++ b/src/main/java/org/breedinginsight/model/BrAPIConstants.java @@ -0,0 +1,18 @@ +package org.breedinginsight.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum BrAPIConstants { + SYSTEM_DEFAULT("System Default"); + + private String value; + + BrAPIConstants(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/main/java/org/breedinginsight/model/Program.java b/src/main/java/org/breedinginsight/model/Program.java index 22616e58e..35cebadb5 100644 --- a/src/main/java/org/breedinginsight/model/Program.java +++ b/src/main/java/org/breedinginsight/model/Program.java @@ -24,6 +24,7 @@ import lombok.experimental.SuperBuilder; import org.brapi.v2.model.core.BrAPIProgram; import org.brapi.v2.model.pheno.BrAPIObservationVariable; +import org.breedinginsight.dao.db.tables.ProgramTable; import org.breedinginsight.dao.db.tables.pojos.ProgramEntity; import org.breedinginsight.dao.db.tables.pojos.SpeciesEntity; import org.jooq.Record; @@ -65,22 +66,26 @@ public Program(ProgramEntity programEntity){ } public static Program parseSQLRecord(Record record){ + return parseSQLRecord(record, PROGRAM); + } + + public static Program parseSQLRecord(Record record, ProgramTable programTable) { // Generate our program record Program program = Program.builder() - .id(record.getValue(PROGRAM.ID)) - .name(record.getValue(PROGRAM.NAME)) - .abbreviation(record.getValue(PROGRAM.ABBREVIATION)) - .objective(record.getValue(PROGRAM.OBJECTIVE)) - .documentationUrl(record.getValue(PROGRAM.DOCUMENTATION_URL)) - .brapiUrl(record.getValue(PROGRAM.BRAPI_URL)) - .key(record.getValue(PROGRAM.KEY)) - .createdAt(record.getValue(PROGRAM.CREATED_AT)) - .updatedAt(record.getValue(PROGRAM.UPDATED_AT)) - .createdBy(record.getValue(PROGRAM.CREATED_BY)) - .updatedBy(record.getValue(PROGRAM.UPDATED_BY)) - .active(record.getValue(PROGRAM.ACTIVE)) - .germplasmSequence(record.getValue(PROGRAM.GERMPLASM_SEQUENCE)) + .id(record.getValue(programTable.ID)) + .name(record.getValue(programTable.NAME)) + .abbreviation(record.getValue(programTable.ABBREVIATION)) + .objective(record.getValue(programTable.OBJECTIVE)) + .documentationUrl(record.getValue(programTable.DOCUMENTATION_URL)) + .brapiUrl(record.getValue(programTable.BRAPI_URL)) + .key(record.getValue(programTable.KEY)) + .createdAt(record.getValue(programTable.CREATED_AT)) + .updatedAt(record.getValue(programTable.UPDATED_AT)) + .createdBy(record.getValue(programTable.CREATED_BY)) + .updatedBy(record.getValue(programTable.UPDATED_BY)) + .active(record.getValue(programTable.ACTIVE)) + .germplasmSequence(record.getValue(programTable.GERMPLASM_SEQUENCE)) .build(); return program; diff --git a/src/main/java/org/breedinginsight/model/SharedProgram.java b/src/main/java/org/breedinginsight/model/SharedProgram.java new file mode 100644 index 000000000..24c12f0ea --- /dev/null +++ b/src/main/java/org/breedinginsight/model/SharedProgram.java @@ -0,0 +1,18 @@ +package org.breedinginsight.model; + +import lombok.*; + +import java.util.UUID; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SharedProgram { + private UUID programId; + private String programName; + private Boolean shared; + private Boolean accepted; + private Boolean editable; +} diff --git a/src/main/java/org/breedinginsight/services/OntologyService.java b/src/main/java/org/breedinginsight/services/OntologyService.java new file mode 100644 index 000000000..8a515e0cb --- /dev/null +++ b/src/main/java/org/breedinginsight/services/OntologyService.java @@ -0,0 +1,237 @@ +package org.breedinginsight.services; + +import io.micronaut.http.HttpStatus; +import io.micronaut.http.exceptions.HttpStatusException; +import org.breedinginsight.api.auth.AuthenticatedUser; +import org.breedinginsight.api.model.v1.request.SharedOntologyProgramRequest; +import org.breedinginsight.api.model.v1.response.ValidationError; +import org.breedinginsight.api.model.v1.response.ValidationErrors; +import org.breedinginsight.dao.db.tables.pojos.ProgramSharedOntologyEntity; +import org.breedinginsight.daos.ProgramDAO; +import org.breedinginsight.daos.ProgramOntologyDAO; +import org.breedinginsight.daos.TraitDAO; +import org.breedinginsight.model.Program; +import org.breedinginsight.model.SharedProgram; +import org.breedinginsight.services.exceptions.DoesNotExistException; +import org.breedinginsight.services.exceptions.UnprocessableEntityException; +import org.breedinginsight.services.exceptions.ValidatorException; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.validation.constraints.NotNull; +import java.util.*; +import java.util.stream.Collectors; + +@Singleton +public class OntologyService { + + private ProgramDAO programDAO; + private ProgramOntologyDAO programOntologyDAO; + private TraitDAO traitDAO; + private TraitService traitService; + + @Inject + public OntologyService(ProgramDAO programDAO, ProgramOntologyDAO programOntologyDAO, TraitDAO traitDAO, TraitService traitService) { + this.programDAO = programDAO; + this.programOntologyDAO = programOntologyDAO; + this.traitDAO = traitDAO; + this.traitService = traitService; + } + + /** + * Gets programs available to share a programs ontology with. + * @param programId -- Program that owns the ontology + * @param sharedOnly -- True = return only shared programs, False = get all shareable programs + * @return List + */ + public List getSharedOntology(@NotNull UUID programId, @NotNull Boolean sharedOnly) { + + // Get program with that id + Program program = getProgram(programId); + + List formattedPrograms = getSharedProgramsFormatted(program); + Set sharedProgramIds = formattedPrograms.stream().map(SharedProgram::getProgramId).collect(Collectors.toSet()); + + // Add other programs + if (!sharedOnly) { + // TODO: Test if localhost vs localhost/brapi/v2 makes a difference + List matchingPrograms = getMatchingPrograms(program); + formattedPrograms.addAll(matchingPrograms.stream() + .filter(matchingProgram -> !sharedProgramIds.contains(matchingProgram.getId())) + .map(matchingProgram -> formatResponse(matchingProgram)) + .collect(Collectors.toList())); + } + + return formattedPrograms; + } + + private List getSharedProgramsFormatted(Program program) { + // Get shared ontology records + List sharedOntologies = programOntologyDAO.getSharedOntologies(program.getId()); + // Get the programs ontology is shared with + List sharedPrograms = programDAO.get( + sharedOntologies.stream().map(ProgramSharedOntologyEntity::getSharedProgramId).collect(Collectors.toList())); + // Get the programs in a lookup map + Map sharedProgramsMap = new HashMap<>(); + sharedPrograms.stream().forEach(sharedProgram -> sharedProgramsMap.put(sharedProgram.getId(), sharedProgram)); + + // Format shared programs response + return sharedOntologies.stream().map(sharedOntology -> + formatResponse(sharedProgramsMap.get(sharedOntology.getSharedProgramId()), sharedOntology, + ontologyIsEditable(sharedOntology))) + .collect(Collectors.toList()); + } + + private List getMatchingPrograms(Program program) { + List allPrograms = programDAO.getAll(); + List matchingPrograms = new ArrayList<>(); + for (Program candidate: allPrograms) { + + if (candidate.getSpecies().getId().equals(program.getSpecies().getId()) && + candidate.getBrapiUrl().equals(program.getBrapiUrl()) && + !candidate.getId().equals(program.getId()) + ) { + + matchingPrograms.add(candidate); + } + } + return matchingPrograms; + } + + private Program getProgram(UUID programId) { + List programs = programDAO.get(programId); + if (programs.size() == 0) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Program with that id does not exist"); + } + return programs.get(0); + } + + private SharedProgram formatResponse(Program program, ProgramSharedOntologyEntity programSharedOntologyEntity, Boolean editable) { + return SharedProgram.builder() + .programId(program.getId()) + .programName(program.getName()) + .shared(true) + .editable(editable) + .accepted(programSharedOntologyEntity.getActive()) + .build(); + } + + private SharedProgram formatResponse(Program program) { + return SharedProgram.builder() + .programId(program.getId()) + .programName(program.getName()) + .shared(false) + .build(); + } + + private Boolean ontologyIsEditable(ProgramSharedOntologyEntity sharedOntologyEntity) { + + if (sharedOntologyEntity.getActive()) { + /* TODO: Add in when checking for edits + // Get all trait ids for the program + List traitIds = traitService.getByProgramId(sharedOntologyEntity.getSharedProgramId(), true).stream() + .map(trait -> trait.getId()) + .collect(Collectors.toList()); + + // Get all observations for the ontology + return traitDAO.getObservationsForTraits(traitIds).isEmpty(); + */ + return true; + } else { + return true; + } + } + + + /** + * Processes share requests for a list of programs. Will return a ValidationError if there are issues + * with any of the shared requests. + * + * @param programId -- Program that owns the ontology + * @param programRequests -- List of programs to share ontology with + * @return List + */ + public List shareOntology(@NotNull UUID programId, AuthenticatedUser actingUser, List programRequests) throws ValidatorException, UnprocessableEntityException { + + // Get program with that id + Program program = getProgram(programId); + + // Don't allow to share with self + for (SharedOntologyProgramRequest request: programRequests) { + if (request.getProgramId().equals(program.getId())) { + throw new UnprocessableEntityException("Program cannot share ontology with itself"); + } + } + + // Don't allow shared if program is already subscribe to shared ontology + if (programOntologyDAO.programSubscribedSharedOntology(programId)) { + throw new UnprocessableEntityException("Program is subscribed to a shared ontology and cannot share its own."); + } + + // Check shareability, same brapi server, same species + List matchingPrograms = getMatchingPrograms(program); + Set matchingProgramsSet = new HashSet<>(); + matchingPrograms.stream().forEach(matchingProgram -> matchingProgramsSet.add(matchingProgram.getId())); + + Set shareProgramIdsSet = new HashSet<>(); + ValidationErrors validationErrors = new ValidationErrors(); + for (int i = 0; i < programRequests.size(); i++) { + SharedOntologyProgramRequest programRequest = programRequests.get(i); + if (!matchingProgramsSet.contains(programRequest.getProgramId())) { + ValidationError error = new ValidationError("program", + String.format("Program %s does not have same species or brapi server.", programRequest.getProgramName()), + HttpStatus.UNPROCESSABLE_ENTITY); + validationErrors.addError(i, error); + } else { + shareProgramIdsSet.add(programRequest.getProgramId()); + } + } + + if (validationErrors.hasErrors()) { + throw new ValidatorException(validationErrors); + } + + // Add shared record to DB + List shareRecords = new ArrayList<>(); + for (UUID shareProgramId: shareProgramIdsSet){ + ProgramSharedOntologyEntity programSharedOntologyEntity = ProgramSharedOntologyEntity.builder() + .programId(programId) + .sharedProgramId(shareProgramId) + .updatedBy(actingUser.getId()) + .createdBy(actingUser.getId()) + .build(); + shareRecords.add(programSharedOntologyEntity); + } + programOntologyDAO.createSharedOntologies(shareRecords); + + // Query return data + return getSharedProgramsFormatted(program).stream() + .filter(sharedProgram -> shareProgramIdsSet.contains(sharedProgram.getProgramId())) + .collect(Collectors.toList()); + } + + /** + * Removes ontology sharing from the specific program. + * @param programId -- Program that owns the ontology. + * @param sharedProgramId -- Program to revoke shared ontology access from + */ + public void revokeOntology(@NotNull UUID programId, @NotNull UUID sharedProgramId) throws UnprocessableEntityException, DoesNotExistException { + // Check that program exists + Program program = getProgram(programId); + + // Check that shared program exists + Optional optionalSharedOntology = programOntologyDAO.getSharedOntologyById(programId, sharedProgramId); + if (optionalSharedOntology.isEmpty()) { + throw new DoesNotExistException("Shared program id was not found"); + } + ProgramSharedOntologyEntity sharedOntology = optionalSharedOntology.get(); + + // Check that shared program is still editable. No observations yet. + if (!ontologyIsEditable(sharedOntology)) { + throw new UnprocessableEntityException("Shared ontology can not be removed from this program."); + } + + // Remove record from db + programOntologyDAO.revokeSharedOntology(sharedOntology); + } +} diff --git a/src/main/resources/db/migration/V0.5.34__created_shared_ontology_tables.sql b/src/main/resources/db/migration/V0.5.34__created_shared_ontology_tables.sql new file mode 100644 index 000000000..abaa1b8fb --- /dev/null +++ b/src/main/resources/db/migration/V0.5.34__created_shared_ontology_tables.sql @@ -0,0 +1,30 @@ +/* + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + CREATE TABLE program_shared_ontology ( + like base_entity INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES, + program_id UUID, + shared_program_id UUID, + active boolean NOT NULL DEFAULT false, + shared_on timestamptz(0) NOT NULL default now(), + like base_edit_track_entity INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES + ); + +ALTER TABLE program_shared_ontology ADD FOREIGN KEY (created_by) REFERENCES bi_user (id); +ALTER TABLE program_shared_ontology ADD FOREIGN KEY (updated_by) REFERENCES bi_user (id); +ALTER TABLE program_shared_ontology ADD FOREIGN KEY (program_id) REFERENCES program (id); +ALTER TABLE program_shared_ontology ADD FOREIGN KEY (shared_program_id) REFERENCES program (id); \ No newline at end of file diff --git a/src/test/java/org/breedinginsight/api/v1/controller/OntologyControllerIntegrationTest.java b/src/test/java/org/breedinginsight/api/v1/controller/OntologyControllerIntegrationTest.java new file mode 100644 index 000000000..7228822db --- /dev/null +++ b/src/test/java/org/breedinginsight/api/v1/controller/OntologyControllerIntegrationTest.java @@ -0,0 +1,407 @@ +package org.breedinginsight.api.v1.controller; + +import com.google.gson.*; +import io.kowalski.fannypack.FannyPack; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.client.RxHttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.netty.cookies.NettyCookie; +import io.micronaut.test.annotation.MicronautTest; +import io.reactivex.Flowable; +import org.brapi.v2.model.pheno.BrAPIObservation; +import org.brapi.v2.model.pheno.BrAPIObservationVariable; +import org.breedinginsight.BrAPITest; +import org.breedinginsight.DatabaseTest; +import org.breedinginsight.TestUtils; +import org.breedinginsight.api.model.v1.request.ProgramRequest; +import org.breedinginsight.api.model.v1.request.SharedOntologyProgramRequest; +import org.breedinginsight.api.model.v1.request.SpeciesRequest; +import org.breedinginsight.dao.db.enums.DataType; +import org.breedinginsight.dao.db.tables.pojos.SpeciesEntity; +import org.breedinginsight.daos.ProgramDAO; +import org.breedinginsight.daos.SpeciesDAO; +import org.breedinginsight.daos.UserDAO; +import org.breedinginsight.model.*; +import org.jooq.DSLContext; +import org.junit.jupiter.api.*; + +import javax.inject.Inject; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static io.micronaut.http.HttpRequest.*; +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OntologyControllerIntegrationTest extends BrAPITest { + + private FannyPack securityFp; + private FannyPack brapiFp; + private FannyPack brapiObservationFp; + private Program mainProgram; + private Program otherProgram; + private Program thirdProgram; + + @Inject + private DSLContext dsl; + @Inject + private ProgramDAO programDAO; + @Inject + private UserDAO userDAO; + @Inject + private SpeciesDAO speciesDAO; + + @Inject + @Client("/${micronaut.bi.api.version}") + private RxHttpClient client; + + private Gson gson = new GsonBuilder().registerTypeAdapter(OffsetDateTime.class, (JsonDeserializer) + (json, type, context) -> OffsetDateTime.parse(json.getAsString())) + .create(); + + @AfterAll + public void finish() { super.stopContainers(); } + + @BeforeAll + void setup() throws Exception { + // Create two programs with fanny pack + securityFp = FannyPack.fill("src/test/resources/sql/ProgramSecuredAnnotationRuleIntegrationTest.sql"); + brapiFp = FannyPack.fill("src/test/resources/sql/brapi/species.sql"); + brapiObservationFp = FannyPack.fill("src/test/resources/sql/brapi/BrAPIOntologyControllerIntegrationTest.sql"); + + User testUser = userDAO.getUserByOrcId(TestTokenValidator.TEST_USER_ORCID).get(); + super.getBrapiDsl().execute(brapiFp.get("InsertSpecies")); + dsl.execute(securityFp.get("InsertSystemRoleAdmin"), testUser.getId().toString()); + + SpeciesEntity validSpecies = speciesDAO.findAll().get(0); + SpeciesRequest speciesRequest = SpeciesRequest.builder() + .commonName(validSpecies.getCommonName()) + .id(validSpecies.getId()) + .build(); + ProgramRequest programRequest1 = ProgramRequest.builder() + .name("Test Program1") + .abbreviation("test1") + .documentationUrl("localhost:8080") + .objective("To test things") + .species(speciesRequest) + .key("OTA") + .build(); + ProgramRequest programRequest2 = ProgramRequest.builder() + .name("Test Program2") + .abbreviation("test2") + .documentationUrl("localhost:8080") + .objective("To test things") + .species(speciesRequest) + .key("OTB") + .build(); + ProgramRequest programRequest3 = ProgramRequest.builder() + .name("Test Program3") + .abbreviation("test3") + .documentationUrl("localhost:8080") + .objective("To test things") + .species(speciesRequest) + .key("OTC") + .build(); + + TestUtils.insertAndFetchTestProgram(gson, client, programRequest1); + TestUtils.insertAndFetchTestProgram(gson, client, programRequest2); + TestUtils.insertAndFetchTestProgram(gson, client, programRequest3); + + // Get main program + List programs = programDAO.getAll(); + mainProgram = programs.get(0); + otherProgram = programs.get(1); + thirdProgram = programs.get(2); + + dsl.execute(securityFp.get("InsertProgramRolesBreeder"), testUser.getId().toString(), mainProgram.getId().toString()); + dsl.execute(securityFp.get("InsertProgramRolesBreeder"), testUser.getId().toString(), otherProgram.getId().toString()); + + // Add trait to program + addTrait(otherProgram); + // Add a single observation to all traits + super.getBrapiDsl().execute(brapiObservationFp.get("AddObservations")); + } + + private void addTrait(Program program) { + + // Add a trait + Trait trait = new Trait(); + trait.setTraitDescription("trait 1 description"); + trait.setEntity("entity1"); + trait.setObservationVariableName("Test Trait"); + trait.setProgramObservationLevel(ProgramObservationLevel.builder().name("Plant").build()); + Scale scale1 = new Scale(); + scale1.setScaleName("Test Scale"); + scale1.setDataType(DataType.TEXT); + Method method1 = new Method(); + trait.setScale(scale1); + trait.setMethod(method1); + trait.setTraitClass("Pheno trait"); + trait.setAttribute("leaf length"); + trait.setMainAbbreviation("abbrev1"); + trait.setSynonyms(List.of("test1", "test2")); + trait.getMethod().setMethodClass("Estimation"); + trait.getMethod().setDescription("A method"); + + List traits = List.of(trait); + + // Call endpoint + Flowable> call = client.exchange( + POST("/programs/" + program.getId() + "/traits", traits) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + HttpResponse response = call.blockingFirst(); + assertEquals(HttpStatus.OK, response.getStatus()); + } + + @Test + @Order(1) + void getAllProgramsNoSharedPrograms() { + String url = String.format("/programs/%s/ontology/shared/programs", mainProgram.getId()); + Flowable> call = client.exchange( + GET(url).cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpResponse response = call.blockingFirst(); + assertEquals(HttpStatus.OK, response.getStatus()); + + JsonObject result = JsonParser.parseString(response.body()).getAsJsonObject().getAsJsonObject("result"); + JsonArray data = result.getAsJsonArray("data"); + assertEquals(2, data.size(), "Wrong number of programs returned"); + + // Check all are not shared and are inactive + for (JsonElement element: data) { + JsonObject program = element.getAsJsonObject(); + + assertFalse(program.get("shared").getAsBoolean(), "Shared should have been false"); + assertNull(program.get("accepted"), "Accepted should have been false"); + assertNull(program.get("editable"), "Editable should have been false"); + } + } + + @Test + @Order(2) + void addSharedPrograms() { + + String url = String.format("/programs/%s/ontology/shared/programs", mainProgram.getId()); + List requests = new ArrayList<>(); + requests.add(new SharedOntologyProgramRequest(otherProgram.getId(), otherProgram.getName())); + String json = gson.toJson(requests); + + Flowable> call = client.exchange( + POST(url, json) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpResponse response = call.blockingFirst(); + assertEquals(HttpStatus.OK, response.getStatus()); + + JsonObject result = JsonParser.parseString(response.body()).getAsJsonObject().getAsJsonObject("result"); + JsonArray data = result.getAsJsonArray("data"); + assertEquals(1, data.size(), "Wrong number of programs returned"); + + // Check all are not shared and are inactive + for (JsonElement element: data) { + JsonObject program = element.getAsJsonObject(); + + assertTrue(program.get("shared").getAsBoolean(), "Shared should have been true"); + assertFalse(program.get("accepted").getAsBoolean(), "Accepted should have been false"); + assertTrue(program.get("editable").getAsBoolean(), "Editable should have been true"); + } + } + + @Test + @Order(2) + void shareOntologySubscribedToOtherProgram() { + // TODO: When subscribe ontology card is done + // Cannot share ontology if you are using a shared ontology + } + + @Test + @Order(3) + void getAllProgramsSharedPrograms() { + String url = String.format("/programs/%s/ontology/shared/programs", mainProgram.getId()); + Flowable> call = client.exchange( + GET(url).cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpResponse response = call.blockingFirst(); + assertEquals(HttpStatus.OK, response.getStatus()); + + JsonObject result = JsonParser.parseString(response.body()).getAsJsonObject().getAsJsonObject("result"); + JsonArray data = result.getAsJsonArray("data"); + assertEquals(2, data.size(), "Wrong number of programs returned"); + + // Check all are not shared and are inactive + for (JsonElement element: data) { + JsonObject program = element.getAsJsonObject(); + + if (program.get("programId").getAsString().equals(otherProgram.getId().toString())) { + assertTrue(program.get("shared").getAsBoolean(), "Shared should have been true"); + assertFalse(program.get("accepted").getAsBoolean(), "Accepted should have been false"); + assertTrue(program.get("editable").getAsBoolean(), "Editable should have been false"); + } else { + assertFalse(program.get("shared").getAsBoolean(), "Shared should have been false"); + assertNull(program.get("accepted"), "Accepted should have been false"); + assertNull(program.get("editable"), "Editable should have been false"); + } + + } + } + + @Test + @Order(3) + void getOnlySharedPrograms() { + String url = String.format("/programs/%s/ontology/shared/programs?shared=true", mainProgram.getId()); + Flowable> call = client.exchange( + GET(url).cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpResponse response = call.blockingFirst(); + assertEquals(HttpStatus.OK, response.getStatus()); + + JsonObject result = JsonParser.parseString(response.body()).getAsJsonObject().getAsJsonObject("result"); + JsonArray data = result.getAsJsonArray("data"); + assertEquals(1, data.size(), "Wrong number of programs returned"); + + // Check all are not shared and are inactive + for (JsonElement element: data) { + JsonObject program = element.getAsJsonObject(); + + assertTrue(program.get("shared").getAsBoolean(), "Shared should have been true"); + assertFalse(program.get("accepted").getAsBoolean(), "Accepted should have been false"); + assertTrue(program.get("editable").getAsBoolean(), "Editable should have been false"); + } + } + + @Test + @Order(4) + void revokeOntologyUneditable() { + // TODO: When subscribe ontology card is done + // Ontology cannot be revoke if shared program has accepted and has observations + } + + @Test + @Order(5) + void revokeProgram() { + + String url = String.format("/programs/%s/ontology/shared/programs/%s", mainProgram.getId(), otherProgram.getId()); + Flowable> call = client.exchange( + DELETE(url).cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpResponse response = call.blockingFirst(); + assertEquals(HttpStatus.OK, response.getStatus()); + } + + @Test + void shareSelfError() { + // Test that program cannot share with themselves + + String url = String.format("/programs/%s/ontology/shared/programs", mainProgram.getId()); + List requests = new ArrayList<>(); + requests.add(new SharedOntologyProgramRequest(mainProgram.getId(), mainProgram.getName())); + String json = gson.toJson(requests); + + Flowable> call = client.exchange( + POST(url, json) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpClientResponseException e = Assertions.assertThrows(HttpClientResponseException.class, () -> { + HttpResponse response = call.blockingFirst(); + }); + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, e.getStatus()); + } + + @Test + void revokeOntologyProgramNotExist() { + String url = String.format("/programs/%s/ontology/shared/programs/%s", UUID.randomUUID(), otherProgram.getId()); + Flowable> call = client.exchange( + DELETE(url).cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpClientResponseException e = Assertions.assertThrows(HttpClientResponseException.class, () -> { + HttpResponse response = call.blockingFirst(); + }); + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + } + + @Test + void revokeOntologySharedProgramNotExist() { + String url = String.format("/programs/%s/ontology/shared/programs/%s", mainProgram.getId(), UUID.randomUUID()); + Flowable> call = client.exchange( + DELETE(url).cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpClientResponseException e = Assertions.assertThrows(HttpClientResponseException.class, () -> { + HttpResponse response = call.blockingFirst(); + }); + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + } + + @Test + void revokeOntologySharedProgramNotShared() { + String url = String.format("/programs/%s/ontology/shared/programs/%s", mainProgram.getId(), thirdProgram.getId()); + Flowable> call = client.exchange( + DELETE(url).cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpClientResponseException e = Assertions.assertThrows(HttpClientResponseException.class, () -> { + HttpResponse response = call.blockingFirst(); + }); + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + } + + @Test + void shareOntologyProgramNotExist() { + + String url = String.format("/programs/%s/ontology/shared/programs", mainProgram.getId()); + List requests = new ArrayList<>(); + requests.add(new SharedOntologyProgramRequest(UUID.randomUUID(), "I don't exist")); + String json = gson.toJson(requests); + + Flowable> call = client.exchange( + POST(url, json) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpClientResponseException e = Assertions.assertThrows(HttpClientResponseException.class, () -> { + HttpResponse response = call.blockingFirst(); + }); + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, e.getStatus()); + } + + @Test + void shareOntologySharedProgramNotExist() { + + String url = String.format("/programs/%s/ontology/shared/programs", UUID.randomUUID()); + List requests = new ArrayList<>(); + requests.add(new SharedOntologyProgramRequest(otherProgram.getId(), otherProgram.getName())); + String json = gson.toJson(requests); + + Flowable> call = client.exchange( + POST(url, json) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new NettyCookie("phylo-token", "test-registered-user")), String.class + ); + + HttpClientResponseException e = Assertions.assertThrows(HttpClientResponseException.class, () -> { + HttpResponse response = call.blockingFirst(); + }); + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + } +} diff --git a/src/test/resources/sql/brapi/BrAPIOntologyControllerIntegrationTest.sql b/src/test/resources/sql/brapi/BrAPIOntologyControllerIntegrationTest.sql new file mode 100644 index 000000000..df07c0dfa --- /dev/null +++ b/src/test/resources/sql/brapi/BrAPIOntologyControllerIntegrationTest.sql @@ -0,0 +1,21 @@ +-- name: CopyrightNotice +/* + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +-- name: AddObservations +insert into observation (id, observation_variable_id, value) +select row_number() over (order by id), id, 'test' from observation_variable; \ No newline at end of file