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

Time Tracking for Shared Annotations + Editable Mappings #7749

Merged
merged 3 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Added
- Creating and deleting edges is now possible with ctrl+(alt/shift)+leftclick in orthogonal, flight and oblique mode. Also, the flight and oblique modes allow selecting nodes with leftclick, creating new trees with 'c' and deleting the active node with 'del'. [#7678](https://github.com/scalableminds/webknossos/pull/7678)
- Added Typescript defintions for @scalableminds/prop-types package. [#7744](https://github.com/scalableminds/webknossos/pull/7744)
- Time Tracking now also works when editing other users’ shared annotations, and when editing proofreading annotations (a.k.a. editable mappings). [#7749](https://github.com/scalableminds/webknossos/pull/7749)

### Changed
- Improved task list to sort tasks by project date, add option to expand all tasks at once and improve styling. [#7709](https://github.com/scalableminds/webknossos/pull/7709)
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/AnnotationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class AnnotationController @Inject()(
_ <- Fox.runOptional(request.identity) { user =>
if (typedTyp == AnnotationType.Task || typedTyp == AnnotationType.Explorational) {
timeSpanService
.logUserInteraction(Instant(timestamp), user, annotation) // log time when a user starts working
.logUserInteractionIfTheyArePotentialContributor(Instant(timestamp), user, annotation) // log time when a user starts working
} else Fox.successful(())
}
_ = Fox.runOptional(request.identity)(user => userDAO.updateLastActivity(user._id))
Expand Down Expand Up @@ -403,7 +403,7 @@ class AnnotationController @Inject()(
restrictions <- provider.restrictionsFor(typ, id) ?~> "restrictions.notFound" ~> NOT_FOUND
message <- annotationService.finish(annotation, issuingUser, restrictions) ?~> "annotation.finish.failed"
updated <- provider.provideAnnotation(typ, id, issuingUser)
_ <- timeSpanService.logUserInteraction(timestamp, issuingUser, annotation) // log time on tracing end
_ <- timeSpanService.logUserInteractionIfTheyArePotentialContributor(timestamp, issuingUser, annotation) // log time on tracing end
} yield (updated, message)

def finish(typ: String, id: String, timestamp: Long): Action[AnyContent] = sil.SecuredAction.async {
Expand Down
6 changes: 3 additions & 3 deletions app/models/user/time/TimeSpan.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import scala.concurrent.duration.FiniteDuration
case class TimeSpan(
_id: ObjectId,
_user: ObjectId,
_annotation: Option[ObjectId],
_annotation: Option[ObjectId], // Optional for compatibility with legacy data. All new timespans have an annotation.
time: Long,
lastUpdate: Instant,
numberOfUpdates: Long = 0,
Expand All @@ -38,8 +38,8 @@ object TimeSpan {
def groupByDay(timeSpan: TimeSpan): Day =
Day(timeSpan.created.dayOfMonth, timeSpan.created.monthOfYear, timeSpan.created.year)

def fromInstant(timestamp: Instant, _user: ObjectId, _annotation: Option[ObjectId]): TimeSpan =
TimeSpan(ObjectId.generate, _user, _annotation, time = 0L, lastUpdate = timestamp, created = timestamp)
def fromInstant(timestamp: Instant, userId: ObjectId, annotationId: ObjectId): TimeSpan =
TimeSpan(ObjectId.generate, userId, Some(annotationId), time = 0L, lastUpdate = timestamp, created = timestamp)

}

Expand Down
28 changes: 14 additions & 14 deletions app/models/user/time/TimeSpanService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ class TimeSpanService @Inject()(annotationDAO: AnnotationDAO,
private lazy val Mailer: ActorSelection =
actorSystem.actorSelection("/user/mailActor")

def logUserInteraction(timestamp: Instant, user: User, annotation: Annotation)(
def logUserInteractionIfTheyArePotentialContributor(timestamp: Instant, user: User, annotation: Annotation)(
implicit ctx: DBAccessContext): Fox[Unit] =
logUserInteraction(Seq(timestamp), user, annotation)
if (user._id == annotation._user || annotation.othersMayEdit) {
logUserInteraction(Seq(timestamp), user, annotation)
} else Fox.successful(())

def logUserInteraction(timestamps: Seq[Instant], user: User, annotation: Annotation)(
implicit ctx: DBAccessContext): Fox[Unit] =
Expand All @@ -56,21 +58,19 @@ class TimeSpanService @Inject()(annotationDAO: AnnotationDAO,
private val lastUserActivities = mutable.HashMap.empty[ObjectId, TimeSpan]

@SuppressWarnings(Array("TraversableHead", "TraversableLast")) // Only functions call this which put at least one timestamp in the seq
private def trackTime(timestamps: Seq[Instant], _user: ObjectId, _annotation: Annotation)(
private def trackTime(timestamps: Seq[Instant], userId: ObjectId, annotation: Annotation)(
implicit ctx: DBAccessContext): Fox[Unit] =
if (timestamps.isEmpty) {
logger.warn("Timetracking called with empty timestamps list.")
Fox.successful(())
} else {
// Only if the annotation belongs to the user, we are going to log the time on the annotation
val annotation = if (_annotation._user == _user) Some(_annotation) else None
val start = timestamps.head

var timeSpansToInsert: List[TimeSpan] = List()
var timeSpansToUpdate: List[(TimeSpan, Instant)] = List()

def createNewTimeSpan(timestamp: Instant, _user: ObjectId, annotation: Option[Annotation]) = {
val timeSpan = TimeSpan.fromInstant(timestamp, _user, annotation.map(_._id))
def createNewTimeSpan(timestamp: Instant, _user: ObjectId, annotation: Annotation) = {
val timeSpan = TimeSpan.fromInstant(timestamp, _user, annotation._id)
timeSpansToInsert = timeSpan :: timeSpansToInsert
timeSpan
}
Expand All @@ -88,39 +88,39 @@ class TimeSpanService @Inject()(annotationDAO: AnnotationDAO,
}

var current = lastUserActivities
.get(_user)
.get(userId)
.flatMap(lastActivity => {
if (isNotInterrupted(start, lastActivity)) {
if (belongsToSameTracing(lastActivity, annotation)) {
if (belongsToSameAnnotation(lastActivity, annotation)) {
Some(lastActivity)
} else {
updateTimeSpan(lastActivity, start)
None
}
} else None
})
.getOrElse(createNewTimeSpan(start, _user, annotation))
.getOrElse(createNewTimeSpan(start, userId, annotation))

timestamps.sliding(2).foreach { pair =>
val start = pair.head
val end = pair.last
val duration = end - start
if (duration >= conf.WebKnossos.User.timeTrackingPause) {
updateTimeSpan(current, start)
current = createNewTimeSpan(end, _user, annotation)
current = createNewTimeSpan(end, userId, annotation)
}
}
current = updateTimeSpan(current, timestamps.last)
lastUserActivities.update(_user, current)
lastUserActivities.update(userId, current)

flushToDb(timeSpansToInsert, timeSpansToUpdate)(ctx)
}

private def isNotInterrupted(current: Instant, last: TimeSpan) =
current - last.lastUpdate < conf.WebKnossos.User.timeTrackingPause

private def belongsToSameTracing(last: TimeSpan, annotation: Option[Annotation]) =
last._annotation.map(_.id) == annotation.map(_.id)
private def belongsToSameAnnotation(last: TimeSpan, annotation: Annotation) =
last._annotation.contains(annotation._id)

private def logTimeToAnnotation(duration: FiniteDuration, annotation: Option[ObjectId]): Fox[Unit] =
// Log time to annotation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import com.scalableminds.webknossos.datastore.helpers.{
SegmentStatisticsParameters
}
import com.scalableminds.webknossos.datastore.models.datasource.{AdditionalAxis, DataLayer}
import com.scalableminds.webknossos.datastore.models.{WebknossosDataRequest, WebknossosAdHocMeshRequest}
import com.scalableminds.webknossos.datastore.models.{WebknossosAdHocMeshRequest, WebknossosDataRequest}
import com.scalableminds.webknossos.datastore.rpc.RPC
import com.scalableminds.webknossos.datastore.services.{
EditableMappingSegmentListResult,
Expand Down Expand Up @@ -43,7 +43,8 @@ import com.scalableminds.webknossos.tracingstore.{
TSRemoteDatastoreClient,
TSRemoteWebknossosClient,
TracingStoreAccessTokenService,
TracingStoreConfig
TracingStoreConfig,
TracingUpdatesReport
}
import net.liftweb.common.{Box, Empty, Failure, Full}
import play.api.i18n.Messages
Expand Down Expand Up @@ -424,6 +425,15 @@ class VolumeTracingController @Inject()(
_ <- bool2Fox(request.body.length == 1) ?~> "Editable mapping update request must contain exactly one update group"
updateGroup <- request.body.headOption.toFox
_ <- bool2Fox(updateGroup.version == currentVersion + 1) ?~> "version mismatch"
report = TracingUpdatesReport(
tracingId,
timestamps = List(Instant(updateGroup.timestamp)),
statistics = None,
significantChangesCount = updateGroup.actions.length,
viewChangesCount = 0,
urlOrHeaderToken(token, request)
)
_ <- remoteWebknossosClient.reportTracingUpdates(report)
_ <- editableMappingService.update(mappingName, updateGroup, updateGroup.version)
} yield Ok
}
Expand Down