Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Route to import explored datasources automatically #7176

Merged
merged 22 commits into from
Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Added
- Added the option to change the ordering of color layers via drag and drop. This is useful when using the cover blend mode. [#7188](https://github.com/scalableminds/webknossos/pull/7188)
- Added configuration to require users' emails to be verified, added flow to verify emails via link. [#7161](https://github.com/scalableminds/webknossos/pull/7161)
- Added a route to explore and add remote datasets via API. [#7176](https://github.com/scalableminds/webknossos/pull/7176)

### Changed
- Small messages during annotating (e.g. “finished undo”, “applying mapping…”) are now click-through so they do not block users from selecting tools. [7239](https://github.com/scalableminds/webknossos/pull/7239)
Expand Down
1 change: 1 addition & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ UPDATE webknossos.multiUsers SET isEmailVerified = false;

### Postgres Evolutions:
- [105-verify-email.sql](conf/evolutions/105-verify-email.sql)
- [106-folder-no-slashes.sql](conf/evolutions/106-folder-no-slashes.sql)
4 changes: 1 addition & 3 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -577,9 +577,7 @@ class AuthenticationController @Inject()(
) ?~> "user.creation.failed"
_ = analyticsService.track(SignupEvent(user, hadInvite = false))
multiUser <- multiUserDAO.findOne(user._multiUser)
dataStoreToken <- bearerTokenAuthenticatorService
.createAndInit(user.loginInfo, TokenType.DataStore, deleteOld = false)
.toFox
dataStoreToken <- bearerTokenAuthenticatorService.createAndInitDataStoreTokenForUser(user)
_ <- organizationService
.createOrganizationFolder(organization.name, dataStoreToken) ?~> "organization.folderCreation.failed"
} yield {
Expand Down
28 changes: 27 additions & 1 deletion app/controllers/DataSetController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import com.scalableminds.webknossos.datastore.models.datasource.{DataLayer, Elem
import io.swagger.annotations._
import models.analytics.{AnalyticsService, ChangeDatasetSettingsEvent, OpenDatasetEvent}
import models.binary._
import models.binary.explore.{ExploreRemoteDatasetParameters, ExploreRemoteLayerService}
import models.binary.explore.{
ExploreAndAddRemoteDatasetParameters,
ExploreRemoteDatasetParameters,
ExploreRemoteLayerService
}
import models.organization.OrganizationDAO
import models.team.{TeamDAO, TeamService}
import models.user.{User, UserDAO, UserService}
Expand All @@ -25,6 +29,8 @@ import utils.{ObjectId, WkConf}
import javax.inject.Inject
import scala.collection.mutable.ListBuffer
import scala.concurrent.{ExecutionContext, Future}
import com.scalableminds.util.tools.TristateOptionJsonHelper
import models.folder.FolderService

case class DatasetUpdateParameters(
description: Option[Option[String]] = Some(None),
Expand Down Expand Up @@ -60,6 +66,7 @@ class DataSetController @Inject()(userService: UserService,
wKRemoteSegmentAnythingClient: WKRemoteSegmentAnythingClient,
teamService: TeamService,
dataSetDAO: DataSetDAO,
folderService: FolderService,
thumbnailService: ThumbnailService,
thumbnailCachingService: ThumbnailCachingService,
conf: WkConf,
Expand Down Expand Up @@ -125,6 +132,25 @@ class DataSetController @Inject()(userService: UserService,
} yield Ok(Json.obj("dataSource" -> Json.toJson(dataSourceOpt), "report" -> reportMutable.mkString("\n")))
}

@ApiOperation(hidden = true, value = "")
def exploreAndAddRemoteDataset(): Action[ExploreAndAddRemoteDatasetParameters] =
sil.SecuredAction.async(validateJson[ExploreAndAddRemoteDatasetParameters]) { implicit request =>
val reportMutable = ListBuffer[String]()
val adaptedParameters = ExploreRemoteDatasetParameters(request.body.remoteUri, None, None)
for {
dataSource <- exploreRemoteLayerService.exploreRemoteDatasource(List(adaptedParameters),
request.identity,
reportMutable)
_ <- bool2Fox(dataSource.dataLayers.nonEmpty) ?~> "explore.zeroLayers"
folderIdOpt <- Fox.runOptional(request.body.folderPath)(folderPath =>
folderService.getOrCreateFromPathLiteral(folderPath, request.identity._organization)) ?~> "explore.autoAdd.getFolder.failed"
_ <- exploreRemoteLayerService.addRemoteDatasource(dataSource,
request.body.datasetName,
request.identity,
folderIdOpt) ?~> "explore.autoAdd.failed"
} yield Ok
}

@ApiOperation(value = "List all accessible datasets.", nickname = "datasetList")
@ApiResponses(
Array(new ApiResponse(code = 200, message = "JSON list containing one object per resulting dataset."),
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/FolderController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class FolderController @Inject()(
organization <- organizationDAO.findOne(request.identity._organization)
_ <- folderDAO.findOne(idValidated) ?~> "folder.notFound"
- <- Fox.assertTrue(folderDAO.isEditable(idValidated)) ?~> "folder.update.notAllowed" ~> FORBIDDEN
_ <- folderService.assertValidFolderName(params.name)
_ <- folderDAO.updateName(idValidated, params.name) ?~> "folder.update.name.failed"
_ <- folderService
.updateAllowedTeams(idValidated, params.allowedTeams, request.identity) ?~> "folder.update.teams.failed"
Expand Down Expand Up @@ -115,6 +116,7 @@ class FolderController @Inject()(
def create(parentId: String, name: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
parentIdValidated <- ObjectId.fromString(parentId)
_ <- folderService.assertValidFolderName(name)
newFolder = Folder(ObjectId.generate, name)
_ <- folderDAO.findOne(parentIdValidated) ?~> "folder.notFound"
_ <- folderDAO.insertAsChild(parentIdValidated, newFolder) ?~> "folder.create.failed"
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/UserTokenController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class UserTokenController @Inject()(dataSetDAO: DataSetDAO,
def generateTokenForDataStore: Action[AnyContent] = sil.UserAwareAction.async { implicit request =>
val tokenFox: Fox[String] = request.identity match {
case Some(user) =>
bearerTokenService.createAndInit(user.loginInfo, TokenType.DataStore, deleteOld = false).toFox
bearerTokenService.createAndInitDataStoreTokenForUser(user)
case None => Fox.successful("")
}
for {
Expand Down
7 changes: 7 additions & 0 deletions app/models/binary/DataStore.scala
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ class DataStoreDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext
parsed <- parseAll(r)
} yield parsed

def findOneWithUploadsAllowed(implicit ctx: DBAccessContext): Fox[DataStore] =
for {
accessQuery <- readAccessQuery
r <- run(q"select $columns from $existingCollectionName where allowsUpload and $accessQuery".as[DatastoresRow])
parsed <- parseFirst(r, "find one with uploads allowed")
} yield parsed

def updateUrlByName(name: String, url: String): Fox[Unit] = {
val query = for { row <- Datastores if notdel(row) && row.name === name } yield row.url
for { _ <- run(query.update(url)) } yield ()
Expand Down
14 changes: 14 additions & 0 deletions app/models/binary/WKRemoteDataStoreClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package models.binary

import com.scalableminds.util.geometry.{BoundingBox, Vec3Int}
import com.scalableminds.util.tools.Fox
import com.scalableminds.webknossos.datastore.models.datasource.{DataLayer, GenericDataSource}
import com.scalableminds.webknossos.datastore.rpc.RPC
import com.scalableminds.webknossos.datastore.services.DirectoryStorageReport
import com.typesafe.scalalogging.LazyLogging
import controllers.RpcTokenHolder
import play.api.libs.json.JsObject
import play.utils.UriEncoding
import utils.ObjectId

class WKRemoteDataStoreClient(dataStore: DataStore, rpc: RPC) extends LazyLogging {

Expand Down Expand Up @@ -69,4 +71,16 @@ class WKRemoteDataStoreClient(dataStore: DataStore, rpc: RPC) extends LazyLoggin
.silent
.getWithJsonResponse[List[DirectoryStorageReport]]

def addDataSource(organizationName: String,
datasetName: String,
dataSource: GenericDataSource[DataLayer],
folderId: Option[ObjectId],
userToken: String): Fox[Unit] =
for {
_ <- rpc(s"${dataStore.url}/data/datasets/$organizationName/$datasetName")
.addQueryString("token" -> userToken)
.addQueryStringOptional("folderId", folderId.map(_.toString))
.put(dataSource)
} yield ()

}
38 changes: 36 additions & 2 deletions app/models/binary/explore/ExploreRemoteLayerService.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package models.binary.explore

import com.scalableminds.util.accesscontext.DBAccessContext
import com.scalableminds.util.geometry.{Vec3Double, Vec3Int}
import com.scalableminds.util.tools.{Fox, FoxImplicits}
import com.scalableminds.webknossos.datastore.dataformats.n5.{N5DataLayer, N5SegmentationLayer}
Expand All @@ -15,14 +16,18 @@ import com.scalableminds.webknossos.datastore.datareaders.zarr._
import com.scalableminds.webknossos.datastore.datareaders.zarr3.Zarr3ArrayHeader
import com.scalableminds.webknossos.datastore.datavault.VaultPath
import com.scalableminds.webknossos.datastore.models.datasource._
import com.scalableminds.webknossos.datastore.rpc.RPC
import com.scalableminds.webknossos.datastore.storage.{DataVaultService, RemoteSourceDescriptor}
import com.typesafe.scalalogging.LazyLogging
import models.binary.{DataSetService, DataStoreDAO, WKRemoteDataStoreClient}
import models.binary.credential.CredentialService
import models.organization.OrganizationDAO
import models.user.User
import net.liftweb.common.{Empty, Failure, Full}
import net.liftweb.util.Helpers.tryo
import oxalis.security.WkEnv
import oxalis.security.{WkEnv, WkSilhouetteEnvironment}
import play.api.libs.json.{Json, OFormat}
import utils.ObjectId

import java.net.URI
import javax.inject.Inject
Expand All @@ -38,10 +43,25 @@ object ExploreRemoteDatasetParameters {
implicit val jsonFormat: OFormat[ExploreRemoteDatasetParameters] = Json.format[ExploreRemoteDatasetParameters]
}

class ExploreRemoteLayerService @Inject()(credentialService: CredentialService, dataVaultService: DataVaultService)
case class ExploreAndAddRemoteDatasetParameters(remoteUri: String, datasetName: String, folderPath: Option[String])

object ExploreAndAddRemoteDatasetParameters {
implicit val jsonFormat: OFormat[ExploreAndAddRemoteDatasetParameters] =
Json.format[ExploreAndAddRemoteDatasetParameters]
}

class ExploreRemoteLayerService @Inject()(credentialService: CredentialService,
dataVaultService: DataVaultService,
organizationDAO: OrganizationDAO,
dataStoreDAO: DataStoreDAO,
dataSetService: DataSetService,
wkSilhouetteEnvironment: WkSilhouetteEnvironment,
rpc: RPC)
extends FoxImplicits
with LazyLogging {

private lazy val bearerTokenService = wkSilhouetteEnvironment.combinedAuthenticatorService.tokenAuthenticatorService

def exploreRemoteDatasource(
urisWithCredentials: List[ExploreRemoteDatasetParameters],
requestIdentity: WkEnv#I,
Expand All @@ -67,6 +87,20 @@ class ExploreRemoteLayerService @Inject()(credentialService: CredentialService,
)
} yield dataSource

def addRemoteDatasource(dataSource: GenericDataSource[DataLayer],
datasetName: String,
user: User,
folderId: Option[ObjectId])(implicit ctx: DBAccessContext): Fox[Unit] =
for {
organization <- organizationDAO.findOne(user._organization)
dataStore <- dataStoreDAO.findOneWithUploadsAllowed
_ <- dataSetService.assertValidDataSetName(datasetName)
_ <- dataSetService.assertNewDataSetName(datasetName, organization._id) ?~> "dataSet.name.alreadyTaken"
client = new WKRemoteDataStoreClient(dataStore, rpc)
userToken <- bearerTokenService.createAndInitDataStoreTokenForUser(user)
_ <- client.addDataSource(organization.name, datasetName, dataSource, folderId, userToken)
} yield ()

private def makeLayerNamesUnique(layers: List[DataLayer]): List[DataLayer] = {
val namesSetMutable = scala.collection.mutable.Set[String]()
layers.map { layer: DataLayer =>
Expand Down
54 changes: 53 additions & 1 deletion app/models/folder/Folder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package models.folder

import com.scalableminds.util.accesscontext.DBAccessContext
import com.scalableminds.util.tools.Fox
import com.scalableminds.util.tools.Fox.{bool2Fox, option2Fox}
import com.scalableminds.webknossos.schema.Tables._
import com.typesafe.scalalogging.LazyLogging
import models.organization.{Organization, OrganizationDAO}
import models.team.{TeamDAO, TeamService}
import models.user.User
Expand All @@ -14,6 +16,7 @@ import utils.sql.{SQLDAO, SqlClient, SqlToken}
import utils.ObjectId

import javax.inject.Inject
import scala.annotation.tailrec
import scala.concurrent.ExecutionContext

case class Folder(_id: ObjectId, name: String)
Expand All @@ -28,7 +31,8 @@ object FolderParameters {
class FolderService @Inject()(teamDAO: TeamDAO,
teamService: TeamService,
folderDAO: FolderDAO,
organizationDAO: OrganizationDAO)(implicit ec: ExecutionContext) {
organizationDAO: OrganizationDAO)(implicit ec: ExecutionContext)
extends LazyLogging {

val defaultRootName: String = "Datasets"

Expand Down Expand Up @@ -71,6 +75,54 @@ class FolderService @Inject()(teamDAO: TeamDAO,
_ <- teamDAO.updateAllowedTeamsForFolder(folderId, newTeamIds)
} yield ()

def assertValidFolderName(name: String): Fox[Unit] =
bool2Fox(!name.contains("/")) ?~> "folder.nameMustNotContainSlash"

def getOrCreateFromPathLiteral(folderPathLiteral: String, organizationId: ObjectId)(
implicit ctx: DBAccessContext): Fox[ObjectId] =
for {
organization <- organizationDAO.findOne(organizationId)
foldersWithParents: Seq[FolderWithParent] <- folderDAO.findTreeOf(organization._rootFolder)
root <- foldersWithParents.find(_._parent.isEmpty).toFox
_ <- bool2Fox(folderPathLiteral.startsWith("/")) ?~> "pathLiteral.mustStartWithSlash"
pathNames = folderPathLiteral.drop(1).split("/").toList
suppliedRootName <- pathNames.headOption.toFox
_ <- bool2Fox(suppliedRootName == root.name) ?~> "pathLiteral.mustStartAtOrganizationRootFolder"
(existingFolderId, remainingPathNames) = findLowestMatchingFolder(root, foldersWithParents, pathNames)
_ = if (remainingPathNames.nonEmpty) {
logger.info(s"Creating new folder(s) under $existingFolderId by path literal: $remainingPathNames...")
}
targetFolderId <- createMissingFoldersForPathNames(existingFolderId, remainingPathNames)
} yield targetFolderId

private def findLowestMatchingFolder(root: FolderWithParent,
foldersWithParents: Seq[FolderWithParent],
pathNames: List[String]): (ObjectId, List[String]) = {

@tailrec
def findFolderIter(currentParent: FolderWithParent, remainingPathNames: List[String]): (ObjectId, List[String]) = {
val nextOpt = foldersWithParents.find(folder =>
folder._parent.contains(currentParent._id) && remainingPathNames.headOption.contains(folder.name))
nextOpt match {
case Some(next) => findFolderIter(next, remainingPathNames.drop(1))
case None => (currentParent._id, remainingPathNames)
}
}

findFolderIter(root, pathNames.drop(1))
}

private def createMissingFoldersForPathNames(parentFolderId: ObjectId, remainingPathNames: List[String])(
implicit ctx: DBAccessContext): Fox[ObjectId] =
remainingPathNames match {
case pathNamesHead :: pathNamesTail =>
for {
newFolder <- Fox.successful(Folder(ObjectId.generate, pathNamesHead))
_ <- folderDAO.insertAsChild(parentFolderId, newFolder)
folderId <- createMissingFoldersForPathNames(newFolder._id, pathNamesTail)
} yield folderId
case Nil => Fox.successful(parentFolderId)
}
}

class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class WebknossosBearerTokenAuthenticatorService(settings: BearerTokenAuthenticat
case e => throw new AuthenticatorInitializationException(InitError.format(ID, authenticator), e)
}

def createAndInitDataStoreTokenForUser(user: User): Fox[String] =
createAndInit(user.loginInfo, TokenType.DataStore, deleteOld = false)

def createAndInit(loginInfo: LoginInfo, tokenType: TokenType, deleteOld: Boolean = true): Future[String] =
for {
tokenAuthenticator <- create(loginInfo, tokenType)
Expand Down
15 changes: 15 additions & 0 deletions conf/evolutions/106-folder-no-slashes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
START TRANSACTION;

do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 105, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;

DROP VIEW webknossos.folders_;

UPDATE webknossos.folders SET name = REPLACE(name, '/', '_') WHERE name ~ '/';

ALTER TABLE webknossos.folders ADD CONSTRAINT folders_name_check CHECK (name !~ '/');

CREATE VIEW webknossos.folders_ as SELECT * FROM webknossos.folders WHERE NOT isDeleted;

UPDATE webknossos.releaseInformation SET schemaVersion = 106;

COMMIT TRANSACTION;
15 changes: 15 additions & 0 deletions conf/evolutions/reversions/106-folder-no-slashes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
START TRANSACTION;

do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 106, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;

DROP VIEW webknossos.folders_;

-- set cannot be undone (we don’t want to turn all underscores into slashes)

ALTER TABLE webknossos.folders DROP CONSTRAINT folders_name_check;

CREATE VIEW webknossos.folders_ as SELECT * FROM webknossos.folders WHERE NOT isDeleted;

UPDATE webknossos.releaseInformation SET schemaVersion = 105;

COMMIT TRANSACTION;
2 changes: 2 additions & 0 deletions conf/messages
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ dataSet.upload.noFiles=Tried to finish upload with no files. May be a retry of a
dataSet.upload.storageExceeded=Cannot upload dataset because the storage quota of the organization is exceeded.
dataSet.explore.failed.readFile=Failed to read remote file
dataSet.explore.magDtypeMismatch=Element class must be the same for all mags of a layer. Got {0}
dataSet.explore.autoAdd.failed=Failed to automatically import the explored dataset.

dataVault.insert.failed=Failed to store remote file system credential
dataVault.setup.failed=Failed to set up remote file system
Expand Down Expand Up @@ -334,6 +335,7 @@ folder.update.name.failed=Failed to update the folder’s name
folder.update.teams.failed=Failed to update the folder’s allowed teams
folder.create.failed.teams.failed=Failed to create folder in this location
folder.noWriteAccess=No write access in this folder
folder.nameMustNotContainSlash=Folder names cannot contain forward slashes

segmentAnything.notEnabled=AI based quick select is not enabled for this WEBKNOSSOS instance.
segmentAnything.noUri=No Uri for SAM server configured.
Expand Down
Loading