diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 084686a8a6..b0f6035e55 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -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) diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index 7b42522228..041024047f 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -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) diff --git a/app/controllers/AnnotationController.scala b/app/controllers/AnnotationController.scala index 56717885e3..ca0255ac8d 100755 --- a/app/controllers/AnnotationController.scala +++ b/app/controllers/AnnotationController.scala @@ -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" @@ -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" @@ -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) @@ -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) - } - def addSegmentIndicesToAll(parallelBatchCount: Int, dryRun: Boolean, skipTracings: Option[String]): Action[AnyContent] = diff --git a/app/models/annotation/Annotation.scala b/app/models/annotation/Annotation.scala index 6c6af5d987..3ab5690850 100755 --- a/app/models/annotation/Annotation.scala +++ b/app/models/annotation/Annotation.scala @@ -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 @@ -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, @@ -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, @@ -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] -} - class AnnotationLayerDAO @Inject()(SQLClient: SqlClient)(implicit ec: ExecutionContext) extends SimpleSQLDAO(SQLClient) { @@ -218,6 +217,7 @@ class AnnotationDAO @Inject()(sqlClient: SqlClient, annotationLayerDAO: Annotati r.name, viewconfigurationOpt, state, + r.islockedbyowner, parseArrayLiteral(r.tags).toSet, r.tracingtime, typ, @@ -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) + 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], @@ -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, @@ -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) @@ -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) diff --git a/app/models/annotation/AnnotationRestrictions.scala b/app/models/annotation/AnnotationRestrictions.scala index 2a8d0fa449..4d5a27f027 100755 --- a/app/models/annotation/AnnotationRestrictions.scala +++ b/app/models/annotation/AnnotationRestrictions.scala @@ -70,7 +70,7 @@ 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] = @@ -78,7 +78,7 @@ class AnnotationRestrictionDefaults @Inject()(userService: UserService)(implicit 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 */ @@ -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)) } diff --git a/app/models/annotation/AnnotationService.scala b/app/models/annotation/AnnotationService.scala index e10a2a855d..0fe8f2f613 100755 --- a/app/models/annotation/AnnotationService.scala +++ b/app/models/annotation/AnnotationService.scala @@ -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] = { @@ -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, @@ -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, diff --git a/conf/evolutions/115-annotation-locked-by-user.sql b/conf/evolutions/115-annotation-locked-by-user.sql new file mode 100644 index 0000000000..24e5e9c4f3 --- /dev/null +++ b/conf/evolutions/115-annotation-locked-by-user.sql @@ -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; diff --git a/conf/evolutions/reversions/115-annotation-locked-by-user.sql b/conf/evolutions/reversions/115-annotation-locked-by-user.sql new file mode 100644 index 0000000000..e1d2b34dfd --- /dev/null +++ b/conf/evolutions/reversions/115-annotation-locked-by-user.sql @@ -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; diff --git a/conf/messages b/conf/messages index 14785c4f86..9dcda5891e 100644 --- a/conf/messages +++ b/conf/messages @@ -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 diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index be0ffb8a12..d5f272fe2b 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -140,6 +140,7 @@ PATCH /annotations/:typ/finish PATCH /annotations/:typ/:id/reopen controllers.AnnotationController.reopen(typ: String, id: String) PUT /annotations/:typ/:id/reset controllers.AnnotationController.reset(typ: String, id: String) PATCH /annotations/:typ/:id/transfer controllers.AnnotationController.transfer(typ: String, id: String) +PATCH /annotations/:typ/:id/editLockedState controllers.AnnotationController.editLockedState(typ: String, id: String, isLockedByOwner: Boolean) GET /annotations/:id/info controllers.AnnotationController.infoWithoutType(id: String, timestamp: Long) PATCH /annotations/:id/makeHybrid controllers.AnnotationController.makeHybridWithoutType(id: String, fallbackLayerName: Option[String]) @@ -150,7 +151,6 @@ DELETE /annotations/:id POST /annotations/:id/merge/:mergedTyp/:mergedId controllers.AnnotationController.mergeWithoutType(id: String, mergedTyp: String, mergedId: String) GET /annotations/:id/download controllers.AnnotationIOController.downloadWithoutType(id: String, skeletonVersion: Option[Long], volumeVersion: Option[Long], skipVolumeData: Option[Boolean], volumeDataZipFormat: Option[String]) POST /annotations/:id/acquireMutex controllers.AnnotationController.tryAcquiringAnnotationMutex(id: String) -PATCH /annotations/:id/addSegmentIndex controllers.AnnotationController.addSegmentIndex(id: String, tracingId: String) PATCH /annotations/addSegmentIndicesToAll controllers.AnnotationController.addSegmentIndicesToAll(parallelBatchCount: Int, dryRun: Boolean, skipTracings: Option[String]) GET /annotations/:typ/:id/info controllers.AnnotationController.info(typ: String, id: String, timestamp: Long) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 6c21b91e2a..c677da2951 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -610,6 +610,19 @@ export function editAnnotation( }); } +export function editLockedState( + annotationId: string, + annotationType: APIAnnotationType, + isLockedByOwner: boolean, +): Promise { + return Request.receiveJSON( + `/api/annotations/${annotationType}/${annotationId}/editLockedState?isLockedByOwner=${isLockedByOwner}`, + { + method: "PATCH", + }, + ); +} + export function setOthersMayEditForAnnotation( annotationId: string, annotationType: APIAnnotationType, diff --git a/frontend/javascripts/components/async_clickables.tsx b/frontend/javascripts/components/async_clickables.tsx index 69e0fd350c..7db5c1a760 100644 --- a/frontend/javascripts/components/async_clickables.tsx +++ b/frontend/javascripts/components/async_clickables.tsx @@ -66,7 +66,11 @@ export function AsyncLink(props: AsyncButtonProps) { props.icon ); return ( - + {icon} {props.children} diff --git a/frontend/javascripts/dashboard/explorative_annotations_view.tsx b/frontend/javascripts/dashboard/explorative_annotations_view.tsx index 627251877d..b6f9437342 100644 --- a/frontend/javascripts/dashboard/explorative_annotations_view.tsx +++ b/frontend/javascripts/dashboard/explorative_annotations_view.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router-dom"; import { PropTypes } from "@scalableminds/prop-types"; -import { Spin, Input, Table, Button, Modal, Tooltip, Tag, Row, Col, Card } from "antd"; +import { Spin, Input, Table, Button, Modal, Tooltip, Tag, Row, Col, Card, TableProps } from "antd"; import { DownloadOutlined, FolderOpenOutlined, @@ -11,6 +11,8 @@ import { CopyOutlined, TeamOutlined, UserOutlined, + LockOutlined, + UnlockOutlined, } from "@ant-design/icons"; import * as React from "react"; import _ from "lodash"; @@ -31,6 +33,7 @@ import { downloadAnnotation, getCompactAnnotationsForUser, getReadableAnnotations, + editLockedState, } from "admin/admin_rest_api"; import { formatHash, stringToColor } from "libs/format_utils"; import { handleGenericError } from "libs/error_handling"; @@ -54,7 +57,6 @@ import { SearchProps } from "antd/lib/input"; import { getCombinedStatsFromServerAnnotation } from "oxalis/model/accessors/annotation_accessor"; import { AnnotationStats } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; -const { Column } = Table; const { Search } = Input; const pageLength: number = 1000; @@ -145,6 +147,17 @@ class ExplorativeAnnotationsView extends React.PureComponent { } }; + updateTracingInLocalState = ( + tracing: APIAnnotationInfo, + callback: (arg0: APIAnnotationInfo) => APIAnnotationInfo, + ) => { + const tracings = this.getCurrentTracings(); + const newTracings = tracings.map((currentTracing) => + currentTracing.id !== tracing.id ? currentTracing : callback(currentTracing), + ); + this.setModeState({ tracings: newTracings }, this.state.shouldShowArchivedTracings); + }; + setModeState = (modeShape: Partial, useArchivedTracings: boolean) => this.addToShownTracings(modeShape, useArchivedTracings); @@ -259,10 +272,22 @@ class ExplorativeAnnotationsView extends React.PureComponent { state: type === "reopen" ? "Active" : "Finished", }); + setLockedState = async (tracing: APIAnnotationInfo, locked: boolean) => { + try { + const newTracing = await editLockedState(tracing.id, tracing.typ, locked); + Toast.success(messages["annotation.was_edited"]); + this.updateTracingInLocalState(tracing, (_t) => newTracing); + trackAction("Lock/Unlock explorative annotation"); + } catch (error) { + handleGenericError(error as Error, "Could not update the annotation lock state."); + } + }; + renderActions = (tracing: APIAnnotationInfo) => { if (tracing.typ !== "Explorational") { return null; } + const isActiveUserOwner = tracing.owner?.id === this.props.activeUser.id; const { typ, id, state } = tracing; @@ -290,11 +315,35 @@ class ExplorativeAnnotationsView extends React.PureComponent { href="#" onClick={() => this.finishOrReopenAnnotation("finish", tracing)} icon={} + disabled={tracing.isLockedByOwner} + title={tracing.isLockedByOwner ? "Locked annotations cannot be archived." : undefined} > Archive ) : null}
+ this.setLockedState(tracing, !tracing.isLockedByOwner)} + icon={ + tracing.isLockedByOwner ? ( + + ) : ( + + ) + } + > + {tracing.isLockedByOwner ? "Unlock" : "Lock"} + +
); } else { @@ -324,29 +373,14 @@ class ExplorativeAnnotationsView extends React.PureComponent { }; renameTracing(tracing: APIAnnotationInfo, name: string) { - const tracings = this.getCurrentTracings(); - const newTracings = tracings.map((currentTracing) => { - if (currentTracing.id !== tracing.id) { - return currentTracing; - } else { - return update(currentTracing, { - name: { - $set: name, - }, - }); - } - }); - this.setModeState( - { - tracings: newTracings, - }, - this.state.shouldShowArchivedTracings, - ); - editAnnotation(tracing.id, tracing.typ, { - name, - }).then(() => { - Toast.success(messages["annotation.was_edited"]); - }); + editAnnotation(tracing.id, tracing.typ, { name }) + .then(() => { + Toast.success(messages["annotation.was_edited"]); + this.updateTracingInLocalState(tracing, (t) => update(t, { name: { $set: name } })); + }) + .catch((error) => { + handleGenericError(error as Error, "Could not update the annotation name."); + }); } archiveAll = () => { @@ -397,9 +431,9 @@ class ExplorativeAnnotationsView extends React.PureComponent { annotation: APIAnnotationInfo, shouldAddTag: boolean, tag: string, - event: React.SyntheticEvent, + event?: React.SyntheticEvent, ): void => { - event.stopPropagation(); // prevent the onClick event + event?.stopPropagation(); // prevent the onClick event this.setState((prevState) => { const newTracings = prevState.unarchivedModeState.tracings.map((t) => { @@ -598,6 +632,125 @@ class ExplorativeAnnotationsView extends React.PureComponent { return this.getEmptyListPlaceholder(); } + const disabledColor = { color: "var(--ant-color-text-disabled)" }; + const columns: TableProps["columns"] = [ + { + title: "ID", + dataIndex: "id", + width: 100, + render: (__: any, tracing: APIAnnotationInfo) => ( + <> +
{this.renderIdAndCopyButton(tracing)}
+ + {!this.isTracingEditable(tracing) ? ( +
{READ_ONLY_ICON} read-only
+ ) : null} + {tracing.isLockedByOwner ? ( +
+ locked +
+ ) : null} + + ), + sorter: Utils.localeCompareBy((annotation) => annotation.id), + }, + { + title: "Name", + width: 280, + dataIndex: "name", + sorter: Utils.localeCompareBy((annotation) => annotation.name), + render: (_name: string, tracing: APIAnnotationInfo) => + this.renderNameWithDescription(tracing), + }, + { + title: "Owner & Teams", + dataIndex: "owner", + width: 300, + filters: ownerAndTeamsFilters, + filterMode: "tree", + onFilter: (value: string | number | boolean, tracing: APIAnnotationInfo) => + (tracing.owner != null && tracing.owner.id === value.toString()) || + tracing.teams.some((team) => team.id === value), + sorter: Utils.localeCompareBy((annotation) => annotation.owner?.firstName || ""), + render: (owner: APIUser | null, tracing: APIAnnotationInfo) => { + const ownerName = owner != null ? renderOwner(owner) : null; + const teamTags = tracing.teams.map((t) => ( + + {t.name} + + )); + + return ( + <> +
+ + {ownerName} +
+
+
+ {teamTags.length > 0 ? : null} +
+
{teamTags}
+
+ + ); + }, + }, + { + title: "Stats", + width: 150, + render: (__: any, annotation: APIAnnotationInfo) => ( + + ), + }, + { + title: "Tags", + dataIndex: "tags", + render: (tags: Array, annotation: APIAnnotationInfo) => ( +
+ {tags.map((tag) => ( + + ))} + {this.state.shouldShowArchivedTracings ? null : ( + } + onChange={_.partial(this.editTagFromAnnotation, annotation, true)} + /> + )} +
+ ), + }, + { + title: "Modification Date", + dataIndex: "modified", + width: 200, + sorter: Utils.compareBy((annotation) => annotation.modified), + render: (modified) => , + }, + { + width: 200, + fixed: "right", + title: "Actions", + className: "nowrap", + key: "action", + render: (__: any, tracing: APIAnnotationInfo) => this.renderActions(tracing), + }, + ]; + return ( { this.currentPageData = currentPageData; return null; }} - > - ( - <> -
{this.renderIdAndCopyButton(tracing)}
- - {!this.isTracingEditable(tracing) ? ( -
- {READ_ONLY_ICON} - read-only -
- ) : null} - - )} - sorter={Utils.localeCompareBy((annotation) => annotation.id)} - /> - annotation.name)} - render={(_name: string, tracing: APIAnnotationInfo) => - this.renderNameWithDescription(tracing) - } - /> - - (tracing.owner != null && tracing.owner.id === value.toString()) || - tracing.teams.some((team) => team.id === value) - } - sorter={Utils.localeCompareBy((annotation) => annotation.owner?.firstName || "")} - render={(owner: APIUser | null, tracing: APIAnnotationInfo) => { - const ownerName = owner != null ? renderOwner(owner) : null; - const teamTags = tracing.teams.map((t) => ( - - {t.name} - - )); - - return ( - <> -
- - {ownerName} -
-
-
- {teamTags.length > 0 ? : null} -
-
{teamTags}
-
- - ); - }} - /> - ( - - )} - /> - , annotation: APIAnnotationInfo) => ( -
- {tags.map((tag) => ( - - ))} - {this.state.shouldShowArchivedTracings ? null : ( - } - onChange={_.partial(this.editTagFromAnnotation, annotation, true)} - /> - )} -
- )} - /> - ((annotation) => annotation.modified)} - render={(modified) => } - /> - this.renderActions(tracing)} - /> -
+ columns={columns} + /> ); } diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx index d9b7700b28..378bbf92cc 100644 --- a/frontend/javascripts/messages.tsx +++ b/frontend/javascripts/messages.tsx @@ -199,8 +199,12 @@ instead. Only enable this option if you understand its effect. All layers will n "tracing.compound_project_not_found": "It looks like this project does not have a single task completed. Make sure that at least one task of this project is finished to view it.", "tracing.no_allowed_mode": "There was no valid allowed annotation mode specified.", - "tracing.read_only_mode_notification": - "This annotation is in read-only mode and cannot be updated.", + "tracing.read_only_mode_notification": (isAnnotationLockedByUser: boolean, isOwner: boolean) => + isAnnotationLockedByUser + ? `This annotation is in read-only mode and cannot be updated. It is currently locked by ${ + isOwner ? "you" : "the owner" + }.` + : "This annotation is in read-only mode and cannot be updated.", "tracing.volume_missing_segmentation": "Volume is allowed, but segmentation does not exist.", "tracing.volume_layer_name_duplication": "This layer name already exists! Please change it to resolve duplicates.", @@ -294,6 +298,10 @@ instead. Only enable this option if you understand its effect. All layers will n "This annotation is currently being edited by someone else. To avoid conflicts, you can only view it at the moment.", "annotation.acquiringMutexSucceeded": "This annotation is not being edited anymore and available for editing. Reload the page to see its newest version and to edit it.", + "annotation.unlock.success": + "The annotation was successfully unlocked. Reloading this annotation ...", + "annotation.lock.success": + "The annotation was successfully locked. Reloading this annotation ...", "task.bulk_create_invalid": "Can not parse task specification. It includes at least one invalid task.", "task.recommended_configuration": "The author of this task suggests to use these settings:", diff --git a/frontend/javascripts/navbar.tsx b/frontend/javascripts/navbar.tsx index 137c534c9b..60d338b9b2 100644 --- a/frontend/javascripts/navbar.tsx +++ b/frontend/javascripts/navbar.tsx @@ -64,6 +64,8 @@ import { MenuClickEventHandler } from "rc-menu/lib/interface"; import constants from "oxalis/constants"; import { MaintenanceBanner, UpgradeVersionBanner } from "banners"; import { getAntdTheme, getSystemColorTheme } from "theme"; +import { formatUserName } from "oxalis/model/accessors/user_accessor"; +import { isAnnotationOwner as isAnnotationOwnerAccessor } from "oxalis/model/accessors/annotation_accessor"; const { Header } = Layout; @@ -83,6 +85,9 @@ type StateProps = { hasOrganizations: boolean; othersMayEdit: boolean; allowUpdate: boolean; + isLockedByOwner: boolean; + isAnnotationOwner: boolean; + annotationOwnerName: string; blockedByUser: APIUserCompact | null | undefined; navbarHeight: number; }; @@ -758,13 +763,17 @@ function AnnotationLockedByUserTag({ if (blockedByUser == null) { content = ( - Locked by unknown user. + + Locked by unknown user. + ); } else if (blockedByUser.id === activeUser.id) { content = ( - Locked by you. Reload to edit. + + Locked by you. Reload to edit. + ); } else { @@ -775,7 +784,9 @@ function AnnotationLockedByUserTag({ userName: blockingUserName, })} > - Locked by {blockingUserName} + + Locked by {blockingUserName} + ); } @@ -786,6 +797,21 @@ function AnnotationLockedByUserTag({ ); } +function AnnotationLockedByOwnerTag(props: { annotationOwnerName: string; isOwner: boolean }) { + const unlockHintForOwners = props.isOwner + ? " You can unlock the annotation in the navbar annotation menu." + : ""; + const tooltipMessage = + messages["tracing.read_only_mode_notification"](true, props.isOwner) + unlockHintForOwners; + return ( + + + Locked by {props.annotationOwnerName} + + + ); +} + function Navbar({ activeUser, isAuthenticated, @@ -794,7 +820,10 @@ function Navbar({ othersMayEdit, blockedByUser, allowUpdate, + annotationOwnerName, + isLockedByOwner, navbarHeight, + isAnnotationOwner, }: Props) { const history = useHistory(); @@ -857,7 +886,7 @@ function Navbar({ menuItems.push(getTimeTrackingMenu(collapseAllNavItems)); } - if (othersMayEdit && !allowUpdate) { + if (othersMayEdit && !allowUpdate && !isLockedByOwner) { trailingNavItems.push( , ); } + if (isLockedByOwner) { + trailingNavItems.push( + , + ); + } trailingNavItems.push( ({ othersMayEdit: state.tracing.othersMayEdit, blockedByUser: state.tracing.blockedByUser, allowUpdate: state.tracing.restrictions.allowUpdate, + isLockedByOwner: state.tracing.isLockedByOwner, + annotationOwnerName: formatUserName(state.activeUser, state.tracing.owner), + isAnnotationOwner: isAnnotationOwnerAccessor(state), navbarHeight: state.uiInformation.navbarHeight, }); diff --git a/frontend/javascripts/oxalis/default_state.ts b/frontend/javascripts/oxalis/default_state.ts index 562168fa3e..233548203e 100644 --- a/frontend/javascripts/oxalis/default_state.ts +++ b/frontend/javascripts/oxalis/default_state.ts @@ -164,6 +164,7 @@ const defaultState: OxalisState = { mappings: [], skeleton: null, owner: null, + isLockedByOwner: false, contributors: [], othersMayEdit: false, blockedByUser: null, diff --git a/frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts b/frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts index 4887926492..49a25674d6 100644 --- a/frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/annotation_accessor.ts @@ -12,10 +12,18 @@ export function mayEditAnnotationProperties(state: OxalisState) { restrictions.allowUpdate && restrictions.allowSave && activeUser && - owner?.id === activeUser.id + owner?.id === activeUser.id && + !state.tracing.isLockedByOwner ); } +export function isAnnotationOwner(state: OxalisState) { + const activeUser = state.activeUser; + const owner = state.tracing.owner; + + return !!(activeUser && owner?.id === activeUser.id); +} + export type SkeletonTracingStats = { treeCount: number; nodeCount: number; diff --git a/frontend/javascripts/oxalis/model/reducers/reducer_helpers.ts b/frontend/javascripts/oxalis/model/reducers/reducer_helpers.ts index 83f7d35fdf..409cbfde61 100644 --- a/frontend/javascripts/oxalis/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/oxalis/model/reducers/reducer_helpers.ts @@ -96,6 +96,7 @@ export function convertServerAnnotationToFrontendAnnotation(annotation: APIAnnot owner, contributors, othersMayEdit, + isLockedByOwner, annotationLayers, } = annotation; const restrictions = { ...annotation.restrictions, ...annotation.settings }; @@ -107,6 +108,7 @@ export function convertServerAnnotationToFrontendAnnotation(annotation: APIAnnot description, name, annotationType, + isLockedByOwner, tracingStore, owner, contributors, diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts index d1b027a91a..e7a7c26a83 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts @@ -109,8 +109,7 @@ export function addToLayerReducer( volumeTracing: VolumeTracing, position: Vector3, ) { - const { restrictions } = state.tracing; - const { allowUpdate } = restrictions; + const { allowUpdate } = state.tracing.restrictions; if (!allowUpdate || isVolumeAnnotationDisallowedForZoom(state.uiInformation.activeTool, state)) { return state; diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index fae4924627..7b7cd7a3d7 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -197,6 +197,7 @@ export type Annotation = { readonly contributors: APIUserBase[]; readonly othersMayEdit: boolean; readonly blockedByUser: APIUserCompact | null | undefined; + readonly isLockedByOwner: boolean; }; type TracingBase = { readonly createdTimestamp: number; diff --git a/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx b/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx index 0910c17791..d036139382 100644 --- a/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx @@ -48,6 +48,7 @@ import { AsyncButton } from "components/async_clickables"; import { PricingEnforcedBlur } from "components/pricing_enforcers"; import { PricingPlanEnum } from "admin/organization/pricing_plan_utils"; import { mayEditAnnotationProperties } from "oxalis/model/accessors/annotation_accessor"; +import { formatUserName } from "oxalis/model/accessors/user_accessor"; const RadioGroup = Radio.Group; const sharingActiveNode = true; @@ -177,6 +178,7 @@ function _ShareModalView(props: Props) { const dataset = useSelector((state: OxalisState) => state.dataset); const tracing = useSelector((state: OxalisState) => state.tracing); const activeUser = useSelector((state: OxalisState) => state.activeUser); + const isAnnotationLockedByUser = tracing.isLockedByOwner; const annotationVisibility = tracing.visibility; const [visibility, setVisibility] = useState(annotationVisibility); @@ -299,8 +301,12 @@ function _ShareModalView(props: Props) { const maybeShowWarning = () => { let message; - - if (!hasUpdatePermissions) { + if (isAnnotationLockedByUser) { + message = `You can't change the visibility of this annotation because it is locked by ${formatUserName( + activeUser, + tracing.owner, + )}.`; + } else if (!hasUpdatePermissions) { message = "You don't have the permission to edit the visibility of this annotation."; } else if (!dataset.isPublic && visibility === "Public") { message = diff --git a/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx b/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx index da04547bdb..315d83c23c 100644 --- a/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx @@ -22,6 +22,8 @@ import { StopOutlined, VerticalLeftOutlined, VerticalRightOutlined, + UnlockOutlined, + LockOutlined, } from "@ant-design/icons"; import { connect } from "react-redux"; import * as React from "react"; @@ -35,6 +37,7 @@ import { finishAnnotation, reOpenAnnotation, createExplorational, + editLockedState, } from "admin/admin_rest_api"; import { location } from "libs/window"; import { @@ -66,6 +69,7 @@ import { screenshotMenuItem, renderAnimationMenuItem, } from "oxalis/view/action-bar/view_dataset_actions_view"; +import * as Utils from "libs/utils"; import UserLocalStorage from "libs/user_local_storage"; import features from "features"; import { getTracingType } from "oxalis/model/accessors/tracing_accessor"; @@ -95,7 +99,8 @@ type StateProps = { isRenderAnimationModalOpen: boolean; busyBlockingInfo: BusyBlockingInfo; annotationOwner: APIUserBase | null | undefined; - othersMayEdit: boolean; + isAnnotationLockedByUser: boolean; + annotationTags: string[]; }; type Props = OwnProps & StateProps; type State = { @@ -449,6 +454,23 @@ class TracingActionsView extends React.PureComponent { }); }; + handleChangeLockedStateOfAnnotation = async (isLocked: boolean) => { + try { + const { annotationId, annotationType } = this.props; + await editLockedState(annotationId, annotationType, isLocked); + Toast.success( + isLocked ? messages["annotation.lock.success"] : messages["annotation.unlock.success"], + ); + // Give some time to show the toast before reloading the page. + await Utils.sleep(250); + location.reload(); + } catch (error: any) { + const verb = isLocked ? "lock" : "unlock"; + Toast.error(`Could not ${verb} the annotation. ` + error?.message); + console.error(`Could not ${verb} the annotation. `, error); + } + }; + render() { const { viewMode, controlMode } = Store.getState().temporaryConfiguration; const isSkeletonMode = Constants.MODES_SKELETON.includes(viewMode); @@ -461,16 +483,11 @@ class TracingActionsView extends React.PureComponent { activeUser, layoutMenu, busyBlockingInfo, - othersMayEdit, + isAnnotationLockedByUser, annotationOwner, } = this.props; - const copyAnnotationText = - !restrictions.allowUpdate && - activeUser != null && - annotationOwner?.id === activeUser.id && - othersMayEdit - ? "Duplicate" - : "Copy To My Account"; + const isAnnotationOwner = activeUser && annotationOwner?.id === activeUser?.id; + const copyAnnotationText = isAnnotationOwner ? "Duplicate" : "Copy To My Account"; const archiveButtonText = task ? "Finish and go to Dashboard" : "Archive"; const saveButton = restrictions.allowUpdate ? [ @@ -694,6 +711,14 @@ class TracingActionsView extends React.PureComponent { label: "Disable saving", }); } + if (isAnnotationOwner) { + menuItems.push({ + key: "lock-unlock-button", + onClick: () => this.handleChangeLockedStateOfAnnotation(!isAnnotationLockedByUser), + icon: isAnnotationLockedByUser ? : , + label: `${isAnnotationLockedByUser ? "Unlock" : "Lock"} Annotation`, + }); + } return ( <> @@ -729,7 +754,8 @@ function mapStateToProps(state: OxalisState): StateProps { isShareModalOpen: state.uiInformation.showShareModal, isRenderAnimationModalOpen: state.uiInformation.showRenderAnimationModal, busyBlockingInfo: state.uiInformation.busyBlockingInfo, - othersMayEdit: state.tracing.othersMayEdit, + isAnnotationLockedByUser: state.tracing.isLockedByOwner, + annotationTags: state.tracing.tags, }; } diff --git a/frontend/javascripts/oxalis/view/components/categorization_label.tsx b/frontend/javascripts/oxalis/view/components/categorization_label.tsx index a0701ebd83..645754bedd 100644 --- a/frontend/javascripts/oxalis/view/components/categorization_label.tsx +++ b/frontend/javascripts/oxalis/view/components/categorization_label.tsx @@ -16,11 +16,13 @@ type FilterProps = { setTags: (arg0: Array) => void; localStorageSavingKey: string; }; +const LOCKED_TAG_COLOR = "var(--ant-color-warning)"; export default function CategorizationLabel({ tag, kind, onClick, onClose, closable }: LabelProps) { + const color = tag === "locked" ? LOCKED_TAG_COLOR : stringToColor(tag); return ( void; onNameChange: (arg0: string) => void; onColorChange: (arg0: Vector3) => void; - allowUpdate: boolean; + disabled: boolean; + isLockedByOwner: boolean; + isOwner: boolean; }; type State = { isEditing: boolean; @@ -477,7 +479,9 @@ export class UserBoundingBoxInput extends React.PureComponent colorPart * 255) as any as Vector3; const iconStyle = { @@ -496,6 +500,10 @@ export class UserBoundingBoxInput extends React.PureComponent ) : null; + const editingDisallowedExplanation = messages["tracing.read_only_mode_notification"]( + isLockedByOwner, + isOwner, + ); return ( - + {exportColumn} - + {}} - style={allowUpdate ? iconStyle : disabledIconStyle} + onClick={disabled ? () => {} : onDelete} + style={disabled ? disabledIconStyle : iconStyle} /> @@ -561,8 +563,8 @@ export class UserBoundingBoxInput extends React.PureComponent - + diff --git a/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx b/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx index 0f3e803866..a0c8d5cdbe 100644 --- a/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx +++ b/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx @@ -27,6 +27,8 @@ import { hasEditableMapping, isMappingLocked, } from "oxalis/model/accessors/volumetracing_accessor"; +import messages from "messages"; +import { isAnnotationOwner } from "oxalis/model/accessors/annotation_accessor"; const { Option, OptGroup } = Select; @@ -48,6 +50,8 @@ type StateProps = { isMergerModeEnabled: boolean; allowUpdate: boolean; isEditableMappingActive: boolean; + isAnnotationLockedByUser: boolean; + isOwner: boolean; } & typeof mapDispatchToProps; type Props = OwnProps & StateProps; type State = { @@ -133,21 +137,32 @@ class MappingSettingsView extends React.Component { }; render() { - const availableMappings = - this.props.segmentationLayer?.mappings != null ? this.props.segmentationLayer.mappings : []; - const availableAgglomerates = - this.props.segmentationLayer?.agglomerates != null - ? this.props.segmentationLayer.agglomerates - : []; + const { + segmentationLayer, + mappingName, + editableMapping, + isMergerModeEnabled, + mapping, + hideUnmappedIds, + isMappingEnabled, + isMappingLocked, + allowUpdate, + isEditableMappingActive, + isAnnotationLockedByUser, + isOwner, + } = this.props; + + const availableMappings = segmentationLayer?.mappings != null ? segmentationLayer.mappings : []; + const availableAgglomerates = segmentationLayer?.agglomerates || []; // Antd does not render the placeholder when a value is defined (even when it's null). // That's why, we only pass the value when it's actually defined. const selectValueProp = - this.props.mappingName != null + mappingName != null ? { value: - this.props.editableMapping != null - ? `${this.props.editableMapping.baseMappingName} (${this.props.mappingName})` - : this.props.mappingName, + editableMapping != null + ? `${editableMapping.baseMappingName} (${mappingName})` + : mappingName, } : {}; @@ -170,17 +185,17 @@ class MappingSettingsView extends React.Component { // The mapping toggle should be active if either the user clicked on it (this.state.shouldMappingBeEnabled) // or a mapping was activated, e.g. from the API or by selecting one from the dropdown (this.props.isMappingEnabled). - const shouldMappingBeEnabled = this.state.shouldMappingBeEnabled || this.props.isMappingEnabled; + const shouldMappingBeEnabled = this.state.shouldMappingBeEnabled || isMappingEnabled; const renderHideUnmappedSegmentsSwitch = - (shouldMappingBeEnabled || this.props.isMergerModeEnabled) && - this.props.mapping && - this.props.hideUnmappedIds != null; - const isDisabled = this.props.isEditableMappingActive || this.props.isMappingLocked; - const disabledMessage = this.props.isEditableMappingActive - ? "The mapping has been edited through proofreading actions and can no longer be disabled or changed." - : this.props.mapping - ? "This mapping has been locked to this annotation, because the segmentation was modified while it was active. It can no longer be disabled or changed." - : "The segmentation was modified while no mapping was active. To ensure a consistent state, mappings can no longer be enabled."; + (shouldMappingBeEnabled || isMergerModeEnabled) && mapping && hideUnmappedIds != null; + const isDisabled = isMappingLocked || !allowUpdate; + const disabledMessage = !allowUpdate + ? messages["tracing.read_only_mode_notification"](isAnnotationLockedByUser, isOwner) + : isEditableMappingActive + ? "The mapping has been edited through proofreading actions and can no longer be disabled or changed." + : mapping + ? "This mapping has been locked to this annotation, because the segmentation was modified while it was active. It can no longer be disabled or changed." + : "The segmentation was modified while no mapping was active. To ensure a consistent state, mappings can no longer be enabled."; return ( { @@ -199,9 +214,7 @@ class MappingSettingsView extends React.Component { value={shouldMappingBeEnabled} label="ID Mapping" // Assume that the mappings are being loaded if they are null - loading={ - shouldMappingBeEnabled && this.props.segmentationLayer?.mappings == null - } + loading={shouldMappingBeEnabled && segmentationLayer?.mappings == null} disabled={isDisabled} /> @@ -234,7 +247,7 @@ class MappingSettingsView extends React.Component { {renderHideUnmappedSegmentsSwitch ? ( @@ -275,6 +288,8 @@ function mapStateToProps(state: OxalisState, ownProps: OwnProps) { editableMapping, isEditableMappingActive: hasEditableMapping(state, ownProps.layerName), isMappingLocked: isMappingLocked(state, ownProps.layerName), + isAnnotationLockedByUser: state.tracing.isLockedByOwner, + isOwner: isAnnotationOwner(state), }; } diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx index 2a2d1364a7..c4901ceb27 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx @@ -11,6 +11,7 @@ import { deleteUserBoundingBoxAction, } from "oxalis/model/actions/annotation_actions"; import { getSomeTracing } from "oxalis/model/accessors/tracing_accessor"; +import { isAnnotationOwner } from "oxalis/model/accessors/annotation_accessor"; import { setPositionAction } from "oxalis/model/actions/flycam_actions"; import * as Utils from "libs/utils"; import { OxalisState, UserBoundingBox } from "oxalis/store"; @@ -22,6 +23,8 @@ export default function BoundingBoxTab() { useState(null); const tracing = useSelector((state: OxalisState) => state.tracing); const allowUpdate = tracing.restrictions.allowUpdate; + const isLockedByOwner = tracing.isLockedByOwner; + const isOwner = useSelector((state: OxalisState) => isAnnotationOwner(state)); const dataset = useSelector((state: OxalisState) => state.dataset); const { userBoundingBoxes } = getSomeTracing(tracing); const dispatch = useDispatch(); @@ -123,7 +126,9 @@ export default function BoundingBoxTab() { onVisibilityChange={_.partial(setBoundingBoxVisibility, bb.id)} onNameChange={_.partial(setBoundingBoxName, bb.id)} onColorChange={_.partial(setBoundingBoxColor, bb.id)} - allowUpdate={allowUpdate} + disabled={allowUpdate} + isLockedByOwner={isLockedByOwner} + isOwner={isOwner} /> )) ) : ( diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/comment_tab/comment_tab_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/comment_tab/comment_tab_view.tsx index 8e80e0defa..30adc0febb 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/comment_tab/comment_tab_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/comment_tab/comment_tab_view.tsx @@ -38,6 +38,7 @@ import messages from "messages"; import AdvancedSearchPopover from "../advanced_search_popover"; import type { MenuProps } from "rc-menu"; import { Comparator } from "types/globals"; +import { isAnnotationOwner } from "oxalis/model/accessors/annotation_accessor"; const commentTabId = "commentTabId"; enum SortByEnum { @@ -75,6 +76,8 @@ function getCommentSorter({ sortBy, isSortedAscending }: SortOptions): Comparato type StateProps = { skeletonTracing: SkeletonTracing | null | undefined; allowUpdate: boolean; + isAnnotationLockedByUser: boolean; + isOwner: boolean; setActiveNode: (nodeId: number) => void; deleteComment: () => void; createComment: (text: string) => void; @@ -433,6 +436,11 @@ class CommentTabView extends React.Component this.getData(), activeCommentMaybe.isJust ? findCommentIndexFn : findTreeIndexFn, ); + const { isAnnotationLockedByUser, isOwner } = this.props; + const isEditingDisabledMessage = messages["tracing.read_only_mode_notification"]( + isAnnotationLockedByUser, + isOwner, + ); return ( @@ -454,11 +462,7 @@ class CommentTabView extends React.Component ) => this.handleChangeInput(evt.target.value, true) } @@ -476,7 +480,7 @@ class CommentTabView extends React.Component title={ this.props.allowUpdate ? "Open dialog to edit comment in multi-line mode" - : messages["tracing.read_only_mode_notification"] + : isEditingDisabledMessage } type={isMultilineComment ? "primary" : "default"} icon={} @@ -542,6 +546,8 @@ class CommentTabView extends React.Component const mapStateToProps = (state: OxalisState) => ({ skeletonTracing: state.tracing.skeleton, allowUpdate: state.tracing.restrictions.allowUpdate, + isAnnotationLockedByUser: state.tracing.isLockedByOwner, + isOwner: isAnnotationOwner(state), }); const mapDispatchToProps = (dispatch: Dispatch) => ({ diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx index 42c088335b..ab2aa01180 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx @@ -99,6 +99,7 @@ import { api } from "oxalis/singletons"; import messages from "messages"; import AdvancedSearchPopover from "./advanced_search_popover"; import DeleteGroupModalView from "./delete_group_modal_view"; +import { isAnnotationOwner } from "oxalis/model/accessors/annotation_accessor"; const { confirm } = Modal; const treeTabId = "tree-list"; @@ -830,6 +831,11 @@ class SkeletonTabView extends React.PureComponent { } const { groupToDelete } = this.state; const isEditingDisabled = !this.props.allowUpdate; + const { isAnnotationLockedByUser, isOwner } = this.props; + const isEditingDisabledMessage = messages["tracing.read_only_mode_notification"]( + isAnnotationLockedByUser, + isOwner, + ); return (
@@ -863,22 +869,14 @@ class SkeletonTabView extends React.PureComponent { @@ -915,11 +913,7 @@ class SkeletonTabView extends React.PureComponent { onChange={this.handleChangeName} value={activeTreeName || activeGroupName} disabled={noTreesAndGroups || isEditingDisabled} - title={ - isEditingDisabled - ? messages["tracing.read_only_mode_notification"] - : undefined - } + title={isEditingDisabled ? isEditingDisabledMessage : undefined} style={{ width: "70%" }} /> ({ skeletonTracing: state.tracing.skeleton, userConfiguration: state.userConfiguration, isSkeletonLayerTransformed: isSkeletonLayerTransformed(state), + isAnnotationLockedByUser: state.tracing.isLockedByOwner, + isOwner: isAnnotationOwner(state), }); const mapDispatchToProps = (dispatch: Dispatch) => ({ diff --git a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts index 142ea63595..691f1b876d 100644 --- a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts @@ -205,4 +205,5 @@ export const annotation: APIAnnotation = { tracingTime: 0, contributors: [], othersMayEdit: false, + isLockedByOwner: false, }; diff --git a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts index 1af0308ee3..71a3e9f689 100644 --- a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts @@ -165,6 +165,7 @@ export const annotation: APIAnnotation = { }, contributors: [], othersMayEdit: false, + isLockedByOwner: false, teams: [ { id: "5b1e45f9a00000a000abc2c3", diff --git a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts index 2ff8136072..d13a74939d 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts @@ -116,4 +116,5 @@ export const annotation: APIAnnotation = { tracingTime: 0, contributors: [], othersMayEdit: false, + isLockedByOwner: false, }; diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md index 0b07edb9e0..2faf8847bf 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md @@ -32,6 +32,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: '570ba0092a7c0e980056fe9b', + isLockedByOwner: false, modified: 0, name: '', organization: 'Organization_X', @@ -152,6 +153,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: '88135c192faeb34c0081c05d', + isLockedByOwner: false, modified: 0, name: '', organization: 'Organization_X', @@ -264,6 +266,7 @@ Generated by [AVA](https://avajs.dev). dataSetName: '2012-06-28_Cortex', description: '', id: 'id', + isLockedByOwner: false, modified: 'modified', name: '', organization: 'Organization_X', @@ -301,6 +304,7 @@ Generated by [AVA](https://avajs.dev). dataSetName: '2012-06-28_Cortex', description: '', id: 'id', + isLockedByOwner: false, modified: 'modified', name: '', organization: 'Organization_X', @@ -338,6 +342,7 @@ Generated by [AVA](https://avajs.dev). dataSetName: '2012-06-28_Cortex', description: '', id: 'id', + isLockedByOwner: false, modified: 'modified', name: '', organization: 'Organization_X', @@ -383,6 +388,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: 'id', + isLockedByOwner: false, messages: [ { success: 'Task is finished', @@ -554,6 +560,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: 'id', + isLockedByOwner: false, messages: [ { success: 'Annotation was reopened', @@ -730,6 +737,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: 'id', + isLockedByOwner: false, messages: [ { success: 'Annotation is archived', @@ -855,6 +863,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: 'id', + isLockedByOwner: false, messages: [ { success: 'Annotation was reopened', @@ -980,6 +989,7 @@ Generated by [AVA](https://avajs.dev). }, description: 'new description', id: 'id', + isLockedByOwner: false, modified: 'modified', name: 'new name', organization: 'Organization_X', @@ -1100,6 +1110,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: 'id', + isLockedByOwner: false, modified: 'modified', name: '', organization: 'Organization_X', diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap index 54be604ede..c0155c71b7 100644 Binary files a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap and b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap differ diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.md index 4d5013d0ae..7641797784 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.md @@ -414,6 +414,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: '58135c402faeb34e0081c068', + isLockedByOwner: false, modified: 0, name: '', organization: 'Organization_X', @@ -697,6 +698,7 @@ Generated by [AVA](https://avajs.dev). }, description: '', id: 'id', + isLockedByOwner: false, modified: 'modified', name: '', organization: 'Organization_X', diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.snap index 3ef7c9f64d..375ba21751 100644 Binary files a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.snap and b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/tasks.e2e.js.snap differ diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/libs/nml.spec.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/libs/nml.spec.js.snap index 046d13f731..be98d92497 100644 Binary files a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/libs/nml.spec.js.snap and b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/libs/nml.spec.js.snap differ diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index e91f4004b0..02f3c699c4 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -463,6 +463,7 @@ export type APIAnnotationInfo = { // backend still serves this for backward-compatibility reasons. readonly stats?: SkeletonTracingStats | EmptyObject; readonly state: string; + readonly isLockedByOwner: boolean; readonly tags: Array; readonly typ: APIAnnotationType; // The owner can be null (e.g., for a sandbox annotation @@ -480,6 +481,7 @@ export function annotationToCompact(annotation: APIAnnotation): APIAnnotationInf id, name, state, + isLockedByOwner, tags, typ, owner, @@ -496,6 +498,7 @@ export function annotationToCompact(annotation: APIAnnotation): APIAnnotationInf description, modified, id, + isLockedByOwner, name, state, tags, diff --git a/frontend/stylesheets/_utils.less b/frontend/stylesheets/_utils.less index be624187e6..91ae318342 100644 --- a/frontend/stylesheets/_utils.less +++ b/frontend/stylesheets/_utils.less @@ -69,13 +69,13 @@ td.nowrap * { .clearfix::before { // sometimes required by .pull-right display: table; - content: ''; + content: ""; } .clearfix::after { // sometimes required by .pull-right display: table; clear: both; - content: ''; + content: ""; } .circle { @@ -97,6 +97,13 @@ td.nowrap * { display: flex; } +.flex-center-child { + display: flex; + justify-content: center; + align-content: center; + flex-wrap: wrap; +} + .flex-item { display: inline-block; flex-grow: 1; diff --git a/test/db/annotations.csv b/test/db/annotations.csv index dce9a89182..c38300142f 100644 --- a/test/db/annotations.csv +++ b/test/db/annotations.csv @@ -1,11 +1,11 @@ -_id,_dataSet,_task,_team,_user,description,visibility,name,viewConfiguration,state,tags,tracingTime,typ,othersMayEdit,created,modified,isDeleted -'570b9ff12a7c0e980056fe8f','570b9f4e4bb848d0885ee711','581367a82faeb37a008a5352','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'570ba0092a7c0e980056fe9b','570b9f4e4bb848d0885ee711',,'570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'Explorational',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'58135c192faeb34c0081c05c','59e9cfbdba632ac2ab8b23b3','58135c192faeb34c0081c058','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'58135c402faeb34e0081c068','570b9f4e4bb848d0885ee711','581367a82faeb37a008a5352','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'Task',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'58135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711','581367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'68135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711',,'570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'Explorational',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'68135c192faeb34c0081c05e','570b9f4e4bb848d0885ee711','681367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'78135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711','681367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'Task',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'78135c192faeb34c0081c05e','570b9f4e4bb848d0885ee711','681367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active','{}',,'Task',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f -'88135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711',,'570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Public','',,'Active','{}',,'Explorational',t,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +_id,_dataSet,_task,_team,_user,description,visibility,name,viewConfiguration,state,isLockedByOwner,tags,tracingTime,typ,othersMayEdit,created,modified,isDeleted +'570b9ff12a7c0e980056fe8f','570b9f4e4bb848d0885ee711','581367a82faeb37a008a5352','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'570ba0092a7c0e980056fe9b','570b9f4e4bb848d0885ee711',,'570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'Explorational',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'58135c192faeb34c0081c05c','59e9cfbdba632ac2ab8b23b3','58135c192faeb34c0081c058','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'58135c402faeb34e0081c068','570b9f4e4bb848d0885ee711','581367a82faeb37a008a5352','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'Task',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'58135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711','581367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'68135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711',,'570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'Explorational',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'68135c192faeb34c0081c05e','570b9f4e4bb848d0885ee711','681367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'TracingBase',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'78135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711','681367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'Task',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'78135c192faeb34c0081c05e','570b9f4e4bb848d0885ee711','681367a82faeb37a008a5354','570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Internal','',,'Active',f,'{}',,'Task',f,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f +'88135c192faeb34c0081c05d','570b9f4e4bb848d0885ee711',,'570b9f4b2a7c0e3b008da6ec','570b9f4d2a7c0e4d008da6ef',,'','Public','',,'Active',f,'{}',,'Explorational',t,'1970-01-01T00:00:00.000Z','1970-01-01T00:00:00.000Z',f diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 6fb2d5e2c3..97072abe0e 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -20,7 +20,7 @@ CREATE TABLE webknossos.releaseInformation ( schemaVersion BIGINT NOT NULL ); -INSERT INTO webknossos.releaseInformation(schemaVersion) values(114); +INSERT INTO webknossos.releaseInformation(schemaVersion) values(115); COMMIT TRANSACTION; @@ -39,6 +39,7 @@ CREATE TABLE webknossos.annotations( name VARCHAR(256) NOT NULL DEFAULT '', viewConfiguration JSONB, state webknossos.ANNOTATION_STATE NOT NULL DEFAULT 'Active', + isLockedByOwner BOOLEAN NOT NULL DEFAULT FALSE, tags VARCHAR(256)[] NOT NULL DEFAULT '{}', tracingTime BIGINT, typ webknossos.ANNOTATION_TYPE NOT NULL,