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

Add option to lock explorative annotations #7801

Merged
merged 26 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
75ddb36
add backend route to update isBlockedByUser state of an annotation
MichaelBuessemeyer May 14, 2024
aec1293
WIP: restrict frontend edits on locked annotations [ci skip]
MichaelBuessemeyer May 14, 2024
d25b004
WIP: blocking edit action in tracing view whn isLockedByUser is activ…
MichaelBuessemeyer May 14, 2024
47b5b0c
add evolution file
MichaelBuessemeyer May 15, 2024
0a48511
improve tooltip messages in locked annotation
MichaelBuessemeyer May 15, 2024
be6a02e
fix backend formatting
MichaelBuessemeyer May 15, 2024
0a65e05
Add some allowUpdate checks to other routes updating an annotation wh…
MichaelBuessemeyer May 16, 2024
85d31ab
fix annotations.csv for e2e tests [ci skip]
MichaelBuessemeyer May 16, 2024
2007f5e
fix/update e2e tests
MichaelBuessemeyer May 16, 2024
81a01c3
Merge branch 'master' of github.com:scalableminds/webknossos into add…
MichaelBuessemeyer May 23, 2024
70935a4
add migration entry
MichaelBuessemeyer May 23, 2024
51993d6
remove locked tag from annotation when unlocked in annotation view
MichaelBuessemeyer May 23, 2024
61fbf22
add changelog entry
MichaelBuessemeyer May 23, 2024
1980d35
clean up and remove allowUpdateAndIsNotLocked function (added in this…
MichaelBuessemeyer May 24, 2024
730c6d3
remove outdated comment
MichaelBuessemeyer May 24, 2024
d77d856
remove migration reason
MichaelBuessemeyer May 28, 2024
4b91dd3
apply pr feedback
MichaelBuessemeyer May 28, 2024
5950da0
refactor explorative annotations table to use column prop instead of …
MichaelBuessemeyer May 28, 2024
6e593ba
replace locked tag with disabled text in id column hinting at a locke…
MichaelBuessemeyer May 28, 2024
f3c4893
Update frontend/javascripts/oxalis/model/reducers/reducer_helpers.ts
MichaelBuessemeyer May 28, 2024
79cd978
update snapshots
MichaelBuessemeyer May 28, 2024
95535b8
Merge branch 'master' into add-locked-state-to-annotation
MichaelBuessemeyer May 29, 2024
c20baf6
make it possible to lock an annotation from annotation view
MichaelBuessemeyer May 30, 2024
581099d
add revision
MichaelBuessemeyer May 30, 2024
073f941
add hint for unlocking for owners to navbar locked tag
MichaelBuessemeyer May 30, 2024
c05b825
Merge branch 'master' of github.com:scalableminds/webknossos into add…
MichaelBuessemeyer May 30, 2024
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 @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/24.06.0...HEAD)

### Added
- Added the option for the owner to lock explorative annotations. Locked annotations cannot be modified by any user. An annotation can be locked in the annotations table and when viewing the annotation via the navbar dropdown menu. [#7801](https://github.com/scalableminds/webknossos/pull/7801)
- Uploading an annotation into a dataset that it was not created for now also works if the dataset is in a different organization. [#7816](https://github.com/scalableminds/webknossos/pull/7816)
- When downloading + reuploading an annotation that is based on a segmentation layer with active mapping, that mapping is now still be selected after the reupload. [#7822](https://github.com/scalableminds/webknossos/pull/7822)

Expand Down
1 change: 1 addition & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).
### Postgres Evolutions:

- [114-ai-models.sql](conf/evolutions/114-ai-models.sql)
- [115-annotation-locked-by-user.sql](conf/evolutions/115-annotation-locked-by-user.sql)
33 changes: 20 additions & 13 deletions app/controllers/AnnotationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,26 @@ class AnnotationController @Inject()(
} yield JsonOk(json, Messages("annotation.reopened"))
}

def editLockedState(typ: String, id: String, isLockedByOwner: Boolean): Action[AnyContent] = sil.SecuredAction.async {
implicit request =>
for {
annotation <- provider.provideAnnotation(typ, id, request.identity)
_ <- bool2Fox(annotation._user == request.identity._id) ?~> "annotation.isLockedByOwner.notAllowed"
_ <- bool2Fox(annotation.typ == AnnotationType.Explorational) ?~> "annotation.isLockedByOwner.explorationalsOnly"
_ = logger.info(
s"Locking annotation $id, new locked state will be ${isLockedByOwner.toString}, access context: ${request.identity.toStringAnonymous}")
_ <- annotationDAO.updateLockedState(annotation._id, isLockedByOwner) ?~> "annotation.invalid"
updatedAnnotation <- provider.provideAnnotation(typ, id, request.identity) ~> NOT_FOUND
json <- annotationService.publicWrites(updatedAnnotation, Some(request.identity)) ?~> "annotation.write.failed"
} yield JsonOk(json, Messages("annotation.isLockedByOwner.success"))
}

def addAnnotationLayer(typ: String, id: String): Action[AnnotationLayerParameters] =
sil.SecuredAction.async(validateJson[AnnotationLayerParameters]) { implicit request =>
for {
_ <- bool2Fox(AnnotationType.Explorational.toString == typ) ?~> "annotation.addLayer.explorationalsOnly"
restrictions <- provider.restrictionsFor(typ, id) ?~> "restrictions.notFound" ~> NOT_FOUND
_ <- restrictions.allowUpdate(request.identity) ?~> "notAllowed" ~> FORBIDDEN
annotation <- provider.provideAnnotation(typ, id, request.identity)
newLayerName = request.body.name.getOrElse(AnnotationLayer.defaultNameForType(request.body.typ))
_ <- bool2Fox(!annotation.annotationLayers.exists(_.name == newLayerName)) ?~> "annotation.addLayer.nameInUse"
Expand Down Expand Up @@ -281,6 +297,8 @@ class AnnotationController @Inject()(
sil.SecuredAction.async { implicit request =>
for {
_ <- bool2Fox(AnnotationType.Explorational.toString == typ) ?~> "annotation.addLayer.explorationalsOnly"
restrictions <- provider.restrictionsFor(typ, id) ?~> "restrictions.notFound" ~> NOT_FOUND
_ <- restrictions.allowUpdate(request.identity) ?~> "notAllowed" ~> FORBIDDEN
annotation <- provider.provideAnnotation(typ, id, request.identity)
organization <- organizationDAO.findOne(request.identity._organization)
_ <- annotationService.makeAnnotationHybrid(annotation, organization.name, fallbackLayerName) ?~> "annotation.makeHybrid.failed"
Expand All @@ -301,6 +319,8 @@ class AnnotationController @Inject()(
implicit request =>
for {
_ <- bool2Fox(AnnotationType.Explorational.toString == typ) ?~> "annotation.downsample.explorationalsOnly"
restrictions <- provider.restrictionsFor(typ, id) ?~> "restrictions.notFound" ~> NOT_FOUND
_ <- restrictions.allowUpdate(request.identity) ?~> "notAllowed" ~> FORBIDDEN
annotation <- provider.provideAnnotation(typ, id, request.identity)
annotationLayer <- annotation.annotationLayers
.find(_.tracingId == tracingId)
Expand All @@ -319,19 +339,6 @@ class AnnotationController @Inject()(
} yield result
}

def addSegmentIndex(id: String, tracingId: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
annotation <- provider.provideAnnotation(id, request.identity)
_ <- bool2Fox(AnnotationType.Explorational == annotation.typ) ?~> "annotation.addSegmentIndex.explorationalsOnly"
annotationLayer <- annotation.annotationLayers
.find(_.tracingId == tracingId)
.toFox ?~> "annotation.addSegmentIndex.layerNotFound"
_ <- annotationService.addSegmentIndex(annotation, annotationLayer) ?~> "annotation.addSegmentIndex.failed"
updated <- provider.provideAnnotation(id, request.identity)
json <- annotationService.publicWrites(updated, Some(request.identity)) ?~> "annotation.write.failed"
} yield JsonOk(json)
}

Comment on lines -322 to -334
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was unused

def addSegmentIndicesToAll(parallelBatchCount: Int,
dryRun: Boolean,
skipTracings: Option[String]): Action[AnyContent] =
Expand Down
116 changes: 57 additions & 59 deletions app/models/annotation/Annotation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import models.annotation.AnnotationType.AnnotationType
import play.api.libs.json._
import slick.jdbc.GetResult._
import slick.jdbc.PostgresProfile.api._
import slick.jdbc.GetResult
import slick.jdbc.TransactionIsolation.Serializable
import slick.lifted.Rep
import slick.sql.SqlAction
Expand All @@ -33,6 +34,7 @@ case class Annotation(
name: String = "",
viewConfiguration: Option[JsObject] = None,
state: AnnotationState.Value = Active,
isLockedByOwner: Boolean = false,
tags: Set[String] = Set.empty,
tracingTime: Option[Long] = None,
typ: AnnotationType.Value = AnnotationType.Explorational,
Expand Down Expand Up @@ -84,6 +86,7 @@ case class AnnotationCompactInfo(id: ObjectId,
modified: Instant,
tags: Set[String],
state: AnnotationState.Value = Active,
isLockedByOwner: Boolean,
dataSetName: String,
visibility: AnnotationVisibility.Value = AnnotationVisibility.Internal,
tracingTime: Option[Long] = None,
Expand All @@ -93,10 +96,6 @@ case class AnnotationCompactInfo(id: ObjectId,
annotationLayerTypes: Seq[String],
annotationLayerStatistics: Seq[JsObject])

object AnnotationCompactInfo {
implicit val jsonFormat: Format[AnnotationCompactInfo] = Json.format[AnnotationCompactInfo]
}

Comment on lines -96 to -99
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was not needed

class AnnotationLayerDAO @Inject()(SQLClient: SqlClient)(implicit ec: ExecutionContext)
extends SimpleSQLDAO(SQLClient) {

Expand Down Expand Up @@ -218,6 +217,7 @@ class AnnotationDAO @Inject()(sqlClient: SqlClient, annotationLayerDAO: Annotati
r.name,
viewconfigurationOpt,
state,
r.islockedbyowner,
parseArrayLiteral(r.tags).toSet,
r.tracingtime,
typ,
Expand Down Expand Up @@ -322,6 +322,43 @@ class AnnotationDAO @Inject()(sqlClient: SqlClient, annotationLayerDAO: Annotati
} yield parsed
}

// Necessary since a tuple can only have 22 elements
implicit def GetResultAnnotationCompactInfo: GetResult[AnnotationCompactInfo] = GetResult { prs =>
import prs._

val id = <<[ObjectId]
val name = <<[String]
val description = <<[String]
val ownerId = <<[ObjectId]
val ownerFirstName = <<[String]
val ownerLastName = <<[String]
val othersMayEdit = <<[Boolean]
val teamIds = parseArrayLiteral(<<[String]).map(ObjectId(_))
val teamNames = parseArrayLiteral(<<[String])
val teamOrganizationIds = parseArrayLiteral(<<[String]).map(ObjectId(_))
val modified = <<[Instant]
val tags = parseArrayLiteral(<<[String]).toSet
val state = AnnotationState.fromString(<<[String]).getOrElse(AnnotationState.Active)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we’re here, could you try out if this and the other enums also work without the String detour, with <<[AnnotationState] (also changing the type in the case class)? In theory, the enums have automatic adapters both to SQL and to JSON.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i tried bug failed 🙈:

I changed to

 val state = <<[AnnotationState]
    val isLockedByOwner = <<[Boolean]
    val dataSetName = <<[String]
    val typ = <<[AnnotationType]
    val visibility = <<[AnnotationVisibility]

But scala could not find the matching GetResult "converters". Thus, I added them as implicit params:

  implicit def GetResultAnnotationCompactInfo(implicit
                                                e0: GetResult[AnnotationState],
                                                e1: GetResult[AnnotationType],
                                                e2: GetResult[AnnotationVisibility]
                                             ): GetResult[AnnotationCompactInfo] = GetResult { prs =>

But then the call run(query.as[AnnotationCompactInfo]) claims it needs to define the implicit params GetResult[AnnotationState]

=> I'd say no to the question / suggestion or we need to define the GetResults somewhere. But I honestly don't know how to do that 🙈

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, thanks for experimenting! Then let’s do this another time. Feel free to go via the string route as is :)

val isLockedByOwner = <<[Boolean]
val dataSetName = <<[String]
val typ = AnnotationType.fromString(<<[String]).getOrElse(AnnotationType.Explorational)
val visibility = AnnotationVisibility.fromString(<<[String]).getOrElse(AnnotationVisibility.Internal)
val tracingTime = Option(<<[Long])
val organizationName = <<[String]
val tracingIds = parseArrayLiteral(<<[String])
val annotationLayerNames = parseArrayLiteral(<<[String])
val annotationLayerTypes = parseArrayLiteral(<<[String])
val annotationLayerStatistics =
parseArrayLiteral(<<[String]).map(layerStats => Json.parse(layerStats).validate[JsObject].getOrElse(Json.obj()))

// format: off
AnnotationCompactInfo(id, typ, name,description,ownerId,ownerFirstName,ownerLastName, othersMayEdit,teamIds,
teamNames,teamOrganizationIds,modified,tags,state,isLockedByOwner,dataSetName,visibility,tracingTime,
organizationName,tracingIds,annotationLayerNames,annotationLayerTypes,annotationLayerStatistics
)
// format: on
}

def findAllListableExplorationals(
isFinished: Option[Boolean],
forUser: Option[ObjectId],
Expand Down Expand Up @@ -366,6 +403,7 @@ class AnnotationDAO @Inject()(sqlClient: SqlClient, annotationLayerDAO: Annotati
a.modified,
a.tags,
a.state,
a.isLockedByOwner,
d.name,
a.typ,
a.visibility,
Expand All @@ -384,67 +422,15 @@ class AnnotationDAO @Inject()(sqlClient: SqlClient, annotationLayerDAO: Annotati
WHERE $stateQuery AND $accessQuery AND $userQuery AND $typQuery
GROUP BY
a._id, a.name, a.description, a._user, a.othersmayedit, a.modified,
a.tags, a.state, a.typ, a.visibility, a.tracingtime,
a.tags, a.state, a.islockedbyowner, a.typ, a.visibility, a.tracingtime,
u.firstname, u.lastname,
teams_agg.team_ids, teams_agg.team_names, teams_agg.team_organization_ids,
d.name, o.name
ORDER BY a._id DESC
LIMIT $limit
OFFSET ${pageNumber * limit}"""
rows <- run(
query.as[
(ObjectId,
String,
String,
ObjectId,
String,
String,
Boolean,
String,
String,
String,
Instant,
String,
String,
String,
String,
String,
Long,
String,
String,
String,
String,
String)])
} yield
rows.toList.map(
r => {
AnnotationCompactInfo(
id = r._1,
name = r._2,
description = r._3,
ownerId = r._4,
ownerFirstName = r._5,
ownerLastName = r._6,
othersMayEdit = r._7,
teamIds = parseArrayLiteral(r._8).map(ObjectId(_)),
teamNames = parseArrayLiteral(r._9),
teamOrganizationIds = parseArrayLiteral(r._10).map(ObjectId(_)),
modified = r._11,
tags = parseArrayLiteral(r._12).toSet,
state = AnnotationState.fromString(r._13).getOrElse(AnnotationState.Active),
dataSetName = r._14,
typ = AnnotationType.fromString(r._15).getOrElse(AnnotationType.Explorational),
visibility = AnnotationVisibility.fromString(r._16).getOrElse(AnnotationVisibility.Internal),
tracingTime = Option(r._17),
organizationName = r._18,
tracingIds = parseArrayLiteral(r._19),
annotationLayerNames = parseArrayLiteral(r._20),
annotationLayerTypes = parseArrayLiteral(r._21),
annotationLayerStatistics = parseArrayLiteral(r._22).map(layerStats =>
Json.parse(layerStats).validate[JsObject].getOrElse(Json.obj()))
)
}
)
rows <- run(query.as[AnnotationCompactInfo])
} yield rows.toList

def countAllListableExplorationals(isFinished: Option[Boolean])(implicit ctx: DBAccessContext): Fox[Long] = {
val stateQuery = getStateQuery(isFinished)
Expand Down Expand Up @@ -692,6 +678,18 @@ class AnnotationDAO @Inject()(sqlClient: SqlClient, annotationLayerDAO: Annotati
_ = logger.info(s"Updated state of Annotation $id to $state, access context: ${ctx.toStringAnonymous}")
} yield ()

def updateLockedState(id: ObjectId, isLocked: Boolean)(implicit ctx: DBAccessContext): Fox[Unit] =
for {
_ <- assertUpdateAccess(id) ?~> "FAILED: AnnotationSQLDAO.assertUpdateAccess"
query = q"UPDATE webknossos.annotations SET isLockedByOwner = $isLocked WHERE _id = $id".asUpdate
_ <- run(
query.withTransactionIsolation(Serializable),
retryCount = 50,
retryIfErrorContains = List(transactionSerializationError)) ?~> "FAILED: run in AnnotationSQLDAO.updateState"
_ = logger.info(
s"Updated isLockedByOwner of Annotation $id to $isLocked, access context: ${ctx.toStringAnonymous}")
} yield ()

def updateDescription(id: ObjectId, description: String)(implicit ctx: DBAccessContext): Fox[Unit] =
for {
_ <- assertUpdateAccess(id)
Expand Down
6 changes: 3 additions & 3 deletions app/models/annotation/AnnotationRestrictions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ class AnnotationRestrictionDefaults @Inject()(userService: UserService)(implicit
accessAllowed <- allowAccess(user)
} yield
user.exists { user =>
(annotation._user == user._id || accessAllowed && annotation.othersMayEdit) && !(annotation.state == Finished)
(annotation._user == user._id || accessAllowed && annotation.othersMayEdit) && !(annotation.state == Finished) && !annotation.isLockedByOwner
}

override def allowFinish(userOption: Option[User]): Fox[Boolean] =
(for {
user <- option2Fox(userOption)
isTeamManagerOrAdminOfTeam <- userService.isTeamManagerOrAdminOf(user, annotation._team)
} yield {
(annotation._user == user._id || isTeamManagerOrAdminOfTeam) && !(annotation.state == Finished)
(annotation._user == user._id || isTeamManagerOrAdminOfTeam) && !(annotation.state == Finished) && !annotation.isLockedByOwner
}).orElse(Fox.successful(false))

/* used in backend only to allow repeatable finish calls */
Expand All @@ -87,7 +87,7 @@ class AnnotationRestrictionDefaults @Inject()(userService: UserService)(implicit
user <- option2Fox(userOption)
isTeamManagerOrAdminOfTeam <- userService.isTeamManagerOrAdminOf(user, annotation._team)
} yield {
annotation._user == user._id || isTeamManagerOrAdminOfTeam
(annotation._user == user._id || isTeamManagerOrAdminOfTeam) && !annotation.isLockedByOwner
}).orElse(Fox.successful(false))
}

Expand Down
11 changes: 2 additions & 9 deletions app/models/annotation/AnnotationService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -436,15 +436,6 @@ class AnnotationService @Inject()(
_ <- annotationLayersDAO.replaceTracingId(annotation._id, volumeAnnotationLayer.tracingId, newVolumeTracingId)
} yield ()

def addSegmentIndex(annotation: Annotation, volumeAnnotationLayer: AnnotationLayer)(
implicit ctx: DBAccessContext): Fox[Unit] =
for {
dataset <- datasetDAO.findOne(annotation._dataset) ?~> "dataset.notFoundForAnnotation"
_ <- bool2Fox(volumeAnnotationLayer.typ == AnnotationLayerType.Volume) ?~> "annotation.segmentIndex.volumeOnly"
rpcClient <- tracingStoreService.clientFor(dataset)
_ <- rpcClient.addSegmentIndex(volumeAnnotationLayer.tracingId, dryRun = false)
} yield ()

// WARNING: needs to be repeatable, might be called multiple times for an annotation
def finish(annotation: Annotation, user: User, restrictions: AnnotationRestrictions)(
implicit ctx: DBAccessContext): Fox[String] = {
Expand Down Expand Up @@ -908,6 +899,7 @@ class AnnotationService @Inject()(
Json.obj(
"modified" -> annotation.modified,
"state" -> annotation.state,
"isLockedByOwner" -> annotation.isLockedByOwner,
"id" -> annotation.id,
"name" -> annotation.name,
"description" -> annotation.description,
Expand Down Expand Up @@ -1010,6 +1002,7 @@ class AnnotationService @Inject()(
"description" -> annotationInfo.description,
"typ" -> annotationInfo.typ,
"stats" -> Json.obj(), // included for legacy parsers
"isLockedByOwner" -> annotationInfo.isLockedByOwner,
"annotationLayers" -> annotationLayerJson,
"dataSetName" -> annotationInfo.dataSetName,
"organization" -> annotationInfo.organizationName,
Expand Down
11 changes: 11 additions & 0 deletions conf/evolutions/115-annotation-locked-by-user.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
START TRANSACTION;

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

DROP VIEW webknossos.annotations_;
ALTER TABLE webknossos.annotations ADD isLockedByOwner BOOLEAN NOT NULL DEFAULT FALSE;
CREATE VIEW webknossos.annotations_ as SELECT * FROM webknossos.annotations WHERE NOT isDeleted;

UPDATE webknossos.releaseInformation SET schemaVersion = 115;

COMMIT TRANSACTION;
11 changes: 11 additions & 0 deletions conf/evolutions/reversions/115-annotation-locked-by-user.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
START TRANSACTION;

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

DROP VIEW webknossos.annotations_;
ALTER TABLE webknossos.annotations DROP COLUMN isLockedByOwner;
CREATE VIEW webknossos.annotations_ as SELECT * FROM webknossos.annotations WHERE NOT isDeleted;

UPDATE webknossos.releaseInformation SET schemaVersion = 114;

COMMIT TRANSACTION;
5 changes: 5 additions & 0 deletions conf/messages
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@ annotation.reopen.tooLate=The annotation cannot be reopened anymore, since it ha
annotation.reopen.notAllowed=You are not allowed to reopen this annotation.
annotation.reopen.notFinished=The requested annotation is not finished.
annotation.reopen.failed=Failed to reopen the annotation.
annotation.reopen.locked=This annotation is locked by the owner and therefore cannot be reopened.
annotation.isLockedByOwner.notAllowed=Only the owner of this annotation is allowed to change the locked state of an annotation.
annotation.isLockedByOwner.explorationalsOnly=Only explorational annotations can be locked.
annotation.isLockedByOwner.failed=Changing the isLockedByOwner state of the annotation failed.
annotation.isLockedByOwner.success=The locking state of the annotation was successfully updated.
annotation.sandbox.skeletonOnly=Sandbox annotations are currently available as skeleton only.
annotation.multiLayers.skeleton.notImplemented=This feature is not implemented for annotations with more than one skeleton layer
annotation.multiLayers.volume.notImplemented=This feature is not implemented for annotations with more than one volume layer
Expand Down
Loading