diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index c376e4ce041..ce872f45202 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -34,6 +34,7 @@ import org.apache.texera.dao.SqlServer import org.apache.texera.dao.SqlServer.withTransaction import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum import org.apache.texera.dao.jooq.generated.tables.Dataset.DATASET +import org.apache.texera.dao.jooq.generated.tables.DatasetContributor.DATASET_CONTRIBUTOR import org.apache.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS import org.apache.texera.dao.jooq.generated.tables.DatasetVersion.DATASET_VERSION import org.apache.texera.dao.jooq.generated.tables.User.USER @@ -175,12 +176,74 @@ object DatasetResource { normalized } + /** + * Helper function to get the contributors using the did + */ + def getContributorsByDid(ctx: DSLContext, did: Integer): List[Contributor] = { + ctx + .selectFrom(DATASET_CONTRIBUTOR) + .where(DATASET_CONTRIBUTOR.DID.eq(did)) + .fetch() + .asScala + .toList + .map { record => + Contributor( + name = record.getName, + creator = record.getCreator, + affiliation = record.getAffiliation, + email = record.getEmail, + comments = record.getComments + ) + } + } + + /** + * Helper function to insert the contributors of a dataset in one batch + */ + def insertContributors(ctx: DSLContext, did: Integer, contributors: List[Contributor]): Unit = { + val records = contributors.map { contributor => + if (contributor == null || contributor.name == null || contributor.name.trim.isEmpty) { + throw new BadRequestException("Each contributor must have a name") + } + if ( + contributor.name.length > 256 || + Option(contributor.email).exists(_.length > 256) || + Option(contributor.affiliation).exists(_.length > 256) + ) { + throw new BadRequestException("Contributor fields must not exceed 256 characters") + } + val record = ctx.newRecord(DATASET_CONTRIBUTOR) + record.setDid(did) + record.setName(contributor.name) + record.setCreator(contributor.creator) + record.setAffiliation(contributor.affiliation) + record.setEmail(contributor.email) + record.setComments(contributor.comments) + record + } + ctx.batchInsert(records.asJava).execute() + } + + case class Contributor( + name: String, + creator: Boolean = false, + affiliation: String = null, + email: String = null, + comments: String = null + ) + + case class DatasetContributorsModification( + did: Integer, + contributors: Option[List[Contributor]] = None + ) + case class DashboardDataset( dataset: Dataset, ownerEmail: String, accessPrivilege: EnumType, isOwner: Boolean, - size: Long + size: Long, + contributors: List[Contributor] = Nil ) case class DashboardDatasetVersion( @@ -192,7 +255,8 @@ object DatasetResource { datasetName: String, datasetDescription: String, isDatasetPublic: Boolean, - isDatasetDownloadable: Boolean + isDatasetDownloadable: Boolean, + contributors: Option[List[Contributor]] = None ) case class Diff( @@ -257,7 +321,8 @@ class DatasetResource extends LazyLogging { isOwner, withLakeFSErrorHandling(s"retrieving the size of dataset '${targetDataset.getName}'") { LakeFSStorageClient.retrieveRepositorySize(targetDataset.getRepositoryName) - } + }, + contributors = DatasetResource.getContributorsByDid(ctx, did) ) } @@ -309,6 +374,9 @@ class DatasetResource extends LazyLogging { .fetchOne() } + val savedContributors = request.contributors.getOrElse(Nil) + DatasetResource.insertContributors(ctx, createdDataset.getDid, savedContributors) + // Initialize the repository in LakeFS val repositoryName = s"dataset-${createdDataset.getDid}" try { @@ -347,7 +415,8 @@ class DatasetResource extends LazyLogging { user.getEmail, PrivilegeEnum.WRITE, isOwner = true, - 0 + 0, + savedContributors ) } } @@ -494,6 +563,34 @@ class DatasetResource extends LazyLogging { } } + @POST + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/update/contributors") + def updateDatasetContributors( + modificator: DatasetContributorsModification, + @Auth user: SessionUser + ): Response = { + withTransaction(context) { ctx => + if (!userHasWriteAccess(ctx, modificator.did, user.getUid)) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) + } + + ctx + .delete(DATASET_CONTRIBUTOR) + .where(DATASET_CONTRIBUTOR.DID.eq(modificator.did)) + .execute() + DatasetResource.insertContributors( + ctx, + modificator.did, + modificator.contributors.getOrElse(Nil) + ) + + Response.ok().build() + } + } + @POST @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala index fb27fa67319..7ca1e9429c6 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala @@ -343,6 +343,40 @@ class DatasetResourceSpec dashboardDataset.dataset.getIsDownloadable shouldBe false } + it should "persist contributors and return them in the response" in { + val contributors = List( + DatasetResource.Contributor( + name = "test1", + creator = true, + affiliation = "Test Lab A", + email = "contributor-a@test.com", + comments = "collected the data" + ), + DatasetResource.Contributor( + name = "test2", + creator = false, + affiliation = "Test Lab B", + email = "contributor-b@test.com", + comments = null + ) + ) + val createDatasetRequest = DatasetResource.CreateDatasetRequest( + datasetName = "contributor-ds", + datasetDescription = "dataset with contributors", + isDatasetPublic = false, + isDatasetDownloadable = true, + contributors = Some(contributors) + ) + + val createdDataset = datasetResource.createDataset(createDatasetRequest, sessionUser) + + createdDataset.contributors should contain theSameElementsAs contributors + DatasetResource.getContributorsByDid( + getDSLContext, + createdDataset.dataset.getDid + ) should contain theSameElementsAs contributors + } + it should "delete dataset successfully if user owns it" in { val dataset = new Dataset dataset.setName("delete-ds") @@ -404,6 +438,197 @@ class DatasetResourceSpec dashboardDataset.size should be >= 0L } + "updateDatasetContributors" should "replace the contributor list of the dataset" in { + val initial = List( + DatasetResource.Contributor( + "test1", + creator = true, + "Test Lab A", + "contributor-a@test.com", + "initial" + ) + ) + val createdDataset = datasetResource.createDataset( + DatasetResource.CreateDatasetRequest( + datasetName = "contributor-update-ds", + datasetDescription = "dataset for contributor update", + isDatasetPublic = false, + isDatasetDownloadable = true, + contributors = Some(initial) + ), + sessionUser + ) + val did = createdDataset.dataset.getDid + + val replacement = List( + DatasetResource + .Contributor("test2", creator = false, "Test Lab C", "contributor-c@test.com", "curation"), + DatasetResource.Contributor( + "test3", + creator = true, + "Test Lab D", + "contributor-d@test.com", + "analysis" + ) + ) + val response = datasetResource.updateDatasetContributors( + DatasetResource.DatasetContributorsModification(did, Some(replacement)), + sessionUser + ) + + response.getStatus shouldEqual 200 + DatasetResource.getContributorsByDid( + getDSLContext, + did + ) should contain theSameElementsAs replacement + } + + it should "clear all contributors when given an empty list" in { + val createdDataset = datasetResource.createDataset( + DatasetResource.CreateDatasetRequest( + datasetName = "contributor-clear-ds", + datasetDescription = "dataset for contributor clear test", + isDatasetPublic = false, + isDatasetDownloadable = true, + contributors = Some( + List( + DatasetResource + .Contributor("test1", creator = true, "Test Lab A", "contributor-a@test.com", null), + DatasetResource + .Contributor("test2", creator = false, "Test Lab B", "contributor-b@test.com", null) + ) + ) + ), + sessionUser + ) + val did = createdDataset.dataset.getDid + DatasetResource.getContributorsByDid(getDSLContext, did) should have size 2 + + val response = datasetResource.updateDatasetContributors( + DatasetResource.DatasetContributorsModification(did, Some(Nil)), + sessionUser + ) + + response.getStatus shouldEqual 200 + DatasetResource.getContributorsByDid(getDSLContext, did) shouldBe empty + } + + it should "reject a contributor without a name" in { + val createdDataset = datasetResource.createDataset( + DatasetResource.CreateDatasetRequest( + datasetName = "contributor-noname-ds", + datasetDescription = "dataset for contributor validation test", + isDatasetPublic = false, + isDatasetDownloadable = true + ), + sessionUser + ) + val did = createdDataset.dataset.getDid + + assertThrows[BadRequestException] { + datasetResource.updateDatasetContributors( + DatasetResource.DatasetContributorsModification( + did, + Some( + List( + DatasetResource + .Contributor(null, creator = false, "Test Lab A", "contributor-x@test.com", null) + ) + ) + ), + sessionUser + ) + } + DatasetResource.getContributorsByDid(getDSLContext, did) shouldBe empty + } + + it should "reject contributor fields longer than 256 characters" in { + val createdDataset = datasetResource.createDataset( + DatasetResource.CreateDatasetRequest( + datasetName = "contributor-toolong-ds", + datasetDescription = "dataset for contributor length test", + isDatasetPublic = false, + isDatasetDownloadable = true + ), + sessionUser + ) + + assertThrows[BadRequestException] { + datasetResource.updateDatasetContributors( + DatasetResource.DatasetContributorsModification( + createdDataset.dataset.getDid, + Some( + List( + DatasetResource + .Contributor( + "A" * 257, + creator = false, + "Test Lab A", + "contributor-x@test.com", + null + ) + ) + ) + ), + sessionUser + ) + } + } + + it should "refuse to update contributors without write access" in { + val createdDataset = datasetResource.createDataset( + DatasetResource.CreateDatasetRequest( + datasetName = "contributor-forbidden-ds", + datasetDescription = "dataset for contributor permission test", + isDatasetPublic = true, + isDatasetDownloadable = true + ), + sessionUser + ) + val did = createdDataset.dataset.getDid + + assertThrows[ForbiddenException] { + datasetResource.updateDatasetContributors( + DatasetResource.DatasetContributorsModification( + did, + Some( + List( + DatasetResource + .Contributor("test1", creator = false, "Test Lab E", "contributor-e@test.com", null) + ) + ) + ), + multipartNoWriteSessionUser + ) + } + + DatasetResource.getContributorsByDid(getDSLContext, did) shouldBe empty + } + + it should "remove contributors when their dataset is deleted" in { + val createdDataset = datasetResource.createDataset( + DatasetResource.CreateDatasetRequest( + datasetName = "contributor-cascade-ds", + datasetDescription = "dataset for contributor cascade test", + isDatasetPublic = false, + isDatasetDownloadable = true, + contributors = Some( + List( + DatasetResource + .Contributor("test1", creator = true, "Test Lab A", "contributor-f@test.com", null) + ) + ) + ), + sessionUser + ) + val did = createdDataset.dataset.getDid + DatasetResource.getContributorsByDid(getDSLContext, did) should have size 1 + + datasetResource.deleteDataset(did, sessionUser).getStatus shouldEqual 200 + + DatasetResource.getContributorsByDid(getDSLContext, did) shouldBe empty + } + "findExistingUploadFiles" should "match committed and staged files by path and size" in { val repoName = s"existing-upload-${System.nanoTime()}" val dataset = new Dataset diff --git a/sql/changelog.xml b/sql/changelog.xml index 2cec53da2f0..0288dbd6b8e 100644 --- a/sql/changelog.xml +++ b/sql/changelog.xml @@ -58,6 +58,11 @@ + + + + +