diff --git a/CHANGELOG.md b/CHANGELOG.md index f537c82064e..e64bedf4a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). - The HTML template now includes SEO tags for demo instances and hides internal instances from search engines. - A maximize-button was added to the viewports in the annotation view. Maximization can also be toggled with the `.` shortcut. [#3876](https://github.com/scalableminds/webknossos/pull/3876) - [webknossos-connect](https://github.com/scalableminds/webknossos-connect) now starts with webKnossos on local and development instances by default. [#3913](https://github.com/scalableminds/webknossos/pull/3913) +- Paginated routes now send a `X-Total-Count` HTTP header which shows how many entries were found in total. [#3899](https://github.com/scalableminds/webknossos/pull/3899) ### Changed - Improved the flight mode performance for tracings with very large trees (>80.000 nodes). [#3880](https://github.com/scalableminds/webknossos/pull/3880) diff --git a/app/controllers/ProjectController.scala b/app/controllers/ProjectController.scala index f923a4e5cb3..1de88693cda 100644 --- a/app/controllers/ProjectController.scala +++ b/app/controllers/ProjectController.scala @@ -10,6 +10,7 @@ import net.liftweb.common.Empty import oxalis.security.WkEnv import com.mohiva.play.silhouette.api.Silhouette import com.mohiva.play.silhouette.api.actions.{SecuredRequest, UserAwareRequest} +import com.scalableminds.util.tools.DefaultConverters.BoolToOption import play.api.i18n.{Messages, MessagesApi} import play.api.libs.json.Json import utils.ObjectId @@ -120,16 +121,25 @@ class ProjectController @Inject()(projectService: ProjectService, Ok(js) } - def tasksForProject(projectName: String, limit: Option[Int] = None, pageNumber: Option[Int] = None) = + def tasksForProject(projectName: String, + limit: Option[Int] = None, + pageNumber: Option[Int] = None, + includeTotalCount: Option[Boolean]) = sil.SecuredAction.async { implicit request => for { project <- projectDAO.findOneByName(projectName) ?~> Messages("project.notFound", projectName) ~> NOT_FOUND _ <- Fox.assertTrue(userService.isTeamManagerOrAdminOf(request.identity, project._team)) ?~> "notAllowed" ~> FORBIDDEN tasks <- taskDAO.findAllByProject(project._id, limit.getOrElse(Int.MaxValue), pageNumber.getOrElse(0))( GlobalAccessContext) + taskCount <- Fox.runOptional(includeTotalCount.flatMap(BoolToOption.convert))(_ => + taskDAO.countAllByProject(project._id)(GlobalAccessContext)) js <- Fox.serialCombined(tasks)(task => taskService.publicWrites(task)) } yield { - Ok(Json.toJson(js)) + val result = Ok(Json.toJson(js)) + taskCount match { + case Some(count) => result.withHeaders("X-Total-Count" -> count.toString) + case None => result + } } } diff --git a/app/controllers/UserController.scala b/app/controllers/UserController.scala index 4099fba7d82..749a59b1f60 100755 --- a/app/controllers/UserController.scala +++ b/app/controllers/UserController.scala @@ -52,7 +52,10 @@ class UserController @Inject()(userService: UserService, } } - def annotations(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int] = None) = + def annotations(isFinished: Option[Boolean], + limit: Option[Int], + pageNumber: Option[Int] = None, + includeTotalCount: Option[Boolean] = None) = sil.SecuredAction.async { implicit request => for { annotations <- annotationDAO.findAllFor(request.identity._id, @@ -60,24 +63,38 @@ class UserController @Inject()(userService: UserService, AnnotationType.Explorational, limit.getOrElse(defaultAnnotationLimit), pageNumber.getOrElse(0)) + annotationCount <- Fox.runOptional(includeTotalCount.flatMap(BoolToOption.convert))(_ => + annotationDAO.countAllFor(request.identity._id, isFinished, AnnotationType.Explorational)) jsonList <- Fox.serialCombined(annotations)(a => annotationService.compactWrites(a)) } yield { - Ok(Json.toJson(jsonList)) + val result = Ok(Json.toJson(jsonList)) + annotationCount match { + case Some(count) => result.withHeaders("X-Total-Count" -> count.toString) + case None => result + } } } - def tasks(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int] = None) = sil.SecuredAction.async { - implicit request => - for { - annotations <- annotationDAO.findAllFor(request.identity._id, - isFinished, - AnnotationType.Task, - limit.getOrElse(defaultAnnotationLimit), - pageNumber.getOrElse(0)) - jsonList <- Fox.serialCombined(annotations)(a => annotationService.publicWrites(a, Some(request.identity))) - } yield { - Ok(Json.toJson(jsonList)) + def tasks(isFinished: Option[Boolean], + limit: Option[Int], + pageNumber: Option[Int] = None, + includeTotalCount: Option[Boolean] = None) = sil.SecuredAction.async { implicit request => + for { + annotations <- annotationDAO.findAllFor(request.identity._id, + isFinished, + AnnotationType.Task, + limit.getOrElse(defaultAnnotationLimit), + pageNumber.getOrElse(0)) + annotationCount <- Fox.runOptional(includeTotalCount.flatMap(BoolToOption.convert))(_ => + annotationDAO.countAllFor(request.identity._id, isFinished, AnnotationType.Task)) + jsonList <- Fox.serialCombined(annotations)(a => annotationService.publicWrites(a, Some(request.identity))) + } yield { + val result = Ok(Json.toJson(jsonList)) + annotationCount match { + case Some(count) => result.withHeaders("X-Total-Count" -> count.toString) + case None => result } + } } def userLoggedTime(userId: String) = sil.SecuredAction.async { implicit request => @@ -132,7 +149,11 @@ class UserController @Inject()(userService: UserService, .map(loggedTime => Ok(Json.toJson(loggedTime))) } - def userAnnotations(userId: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int] = None) = + def userAnnotations(userId: String, + isFinished: Option[Boolean], + limit: Option[Int], + pageNumber: Option[Int] = None, + includeTotalCount: Option[Boolean] = None) = sil.SecuredAction.async { implicit request => for { userIdValidated <- ObjectId.parse(userId) ?~> "user.id.invalid" @@ -143,13 +164,23 @@ class UserController @Inject()(userService: UserService, AnnotationType.Explorational, limit.getOrElse(defaultAnnotationLimit), pageNumber.getOrElse(0)) + annotationCount <- Fox.runOptional(includeTotalCount.flatMap(BoolToOption.convert))(_ => + annotationDAO.countAllFor(userIdValidated, isFinished, AnnotationType.Explorational)) jsonList <- Fox.serialCombined(annotations)(a => annotationService.compactWrites(a)) } yield { - Ok(Json.toJson(jsonList)) + val result = Ok(Json.toJson(jsonList)) + annotationCount match { + case Some(count) => result.withHeaders("X-Total-Count" -> count.toString) + case None => result + } } } - def userTasks(userId: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int] = None) = + def userTasks(userId: String, + isFinished: Option[Boolean], + limit: Option[Int], + pageNumber: Option[Int] = None, + includeTotalCount: Option[Boolean] = None) = sil.SecuredAction.async { implicit request => for { userIdValidated <- ObjectId.parse(userId) ?~> "user.id.invalid" @@ -160,9 +191,15 @@ class UserController @Inject()(userService: UserService, AnnotationType.Task, limit.getOrElse(defaultAnnotationLimit), pageNumber.getOrElse(0)) + annotationCount <- Fox.runOptional(includeTotalCount.flatMap(BoolToOption.convert))(_ => + annotationDAO.countAllFor(userIdValidated, isFinished, AnnotationType.Task)) jsonList <- Fox.serialCombined(annotations)(a => annotationService.publicWrites(a, Some(request.identity))) } yield { - Ok(Json.toJson(jsonList)) + val result = Ok(Json.toJson(jsonList)) + annotationCount match { + case Some(count) => result.withHeaders("X-Total-Count" -> count.toString) + case None => result + } } } diff --git a/app/models/annotation/Annotation.scala b/app/models/annotation/Annotation.scala index c9d3ebe6fbe..6a358b27476 100755 --- a/app/models/annotation/Annotation.scala +++ b/app/models/annotation/Annotation.scala @@ -109,16 +109,19 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContex parsed <- parse(r) ?~> ("SQLDAO Error: Could not parse database row for object " + id + " in " + collectionName) } yield parsed + private def getStateQuery(isFinished: Option[Boolean]) = + isFinished match { + case Some(true) => s"state = '${AnnotationState.Finished.toString}'" + case Some(false) => s"state = '${AnnotationState.Active.toString}'" + case None => s"state != '${AnnotationState.Cancelled.toString}'" + } + def findAllFor(userId: ObjectId, isFinished: Option[Boolean], annotationType: AnnotationType, limit: Int, pageNumber: Int = 0)(implicit ctx: DBAccessContext): Fox[List[Annotation]] = { - val stateQuery = isFinished match { - case Some(true) => s"state = '${AnnotationState.Finished.toString}'" - case Some(false) => s"state = '${AnnotationState.Active.toString}'" - case None => s"state != '${AnnotationState.Cancelled.toString}'" - } + val stateQuery = getStateQuery(isFinished) for { accessQuery <- readAccessQuery r <- run(sql"""select #${columns} from #${existingCollectionName} @@ -128,6 +131,19 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContex } yield parsed } + def countAllFor(userId: ObjectId, isFinished: Option[Boolean], annotationType: AnnotationType)( + implicit ctx: DBAccessContext) = { + val stateQuery = getStateQuery(isFinished) + for { + accessQuery <- readAccessQuery + r <- run( + sql"""select count(*) from #${existingCollectionName} + where _user = ${userId.id} and typ = '#${annotationType.toString}' and #${stateQuery} and #${accessQuery}""" + .as[Int]) + parsed <- r.headOption + } yield parsed + } + // hint: does not use access query (because they dont support prefixes yet). use only after separate access check def findAllFinishedForProject(projectId: ObjectId)(implicit ctx: DBAccessContext): Fox[List[Annotation]] = for { diff --git a/app/models/task/Task.scala b/app/models/task/Task.scala index d969eef7220..0f5465a1f74 100755 --- a/app/models/task/Task.scala +++ b/app/models/task/Task.scala @@ -166,6 +166,15 @@ class TaskDAO @Inject()(sqlClient: SQLClient, projectDAO: ProjectDAO)(implicit e parsed <- Fox.combined(r.toList.map(parse)) } yield parsed + def countAllByProject(projectId: ObjectId)(implicit ctx: DBAccessContext) = + for { + accessQuery <- readAccessQuery + r <- run( + sql"""select count(*) from #${existingCollectionName} where _project = ${projectId.id} and #${accessQuery}""" + .as[Int]) + parsed <- r.headOption + } yield parsed + private def findNextTaskQ(userId: ObjectId, teamIds: List[ObjectId]) = s""" select ${columnsWithPrefix("webknossos.tasks_.")} diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index ed7b3ecf8fd..b2e18149e45 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -38,16 +38,16 @@ GET /user/tasks/peek GET /users controllers.UserController.list GET /user controllers.UserController.current -GET /user/tasks controllers.UserController.tasks(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int]) -GET /user/annotations controllers.UserController.annotations(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int]) +GET /user/tasks controllers.UserController.tasks(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) +GET /user/annotations controllers.UserController.annotations(isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) GET /user/loggedTime controllers.UserController.loggedTime GET /users/:id controllers.UserController.user(id: String) PATCH /users/:id controllers.UserController.update(id: String) PUT /users/:id/taskTypeId controllers.UserController.updateLastTaskTypeId(id: String) -GET /users/:id/tasks controllers.UserController.userTasks(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int]) +GET /users/:id/tasks controllers.UserController.userTasks(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) GET /users/:id/loggedTime controllers.UserController.userLoggedTime(id: String) POST /users/loggedTime controllers.UserController.usersLoggedTime -GET /users/:id/annotations controllers.UserController.userAnnotations(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int]) +GET /users/:id/annotations controllers.UserController.userAnnotations(id: String, isFinished: Option[Boolean], limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) # Team GET /teams controllers.TeamController.list @@ -148,7 +148,7 @@ POST /projects GET /projects/:name controllers.ProjectController.read(name: String) DELETE /projects/:name controllers.ProjectController.delete(name: String) PUT /projects/:name controllers.ProjectController.update(name: String) -GET /projects/:name/tasks controllers.ProjectController.tasksForProject(name: String, limit: Option[Int], pageNumber: Option[Int]) +GET /projects/:name/tasks controllers.ProjectController.tasksForProject(name: String, limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) PATCH /projects/:name/incrementEachTasksInstances controllers.ProjectController.incrementEachTasksInstances(name: String, delta: Option[Long]) PATCH /projects/:name/pause controllers.ProjectController.pause(name: String) PATCH /projects/:name/resume controllers.ProjectController.resume(name: String) diff --git a/docs/rest_api.md b/docs/rest_api.md index 73798459ac0..745faa40965 100644 --- a/docs/rest_api.md +++ b/docs/rest_api.md @@ -60,9 +60,12 @@ List your own task annotations - return only the first x results (defaults to 1000) - Optional GET parameter `pageNumber=[INT]` - return the results starting at offset `limit` * `pageNumber` (defaults to 0) + - Optional GET parameter `includeTotalCount=[BOOLEAN]` + - if true returns the total count of entries (defaults to false) #### Returns - JSON list of objects containing annotation information about your own task annotations, also including task and task type information + - total count of task annotations in the HTTP header `X-Total-Count` if parameter is set --- @@ -78,9 +81,12 @@ List your own explorative annotations - return only the first x results (defaults to 1000) - Optional GET parameter `pageNumber=[INT]` - return the results starting at offset `limit` * `pageNumber` (defaults to 0) + - Optional GET parameter `includeTotalCount=[BOOLEAN]` + - if true returns the total count of entries (defaults to false) #### Returns - JSON list of objects containing annotation information about your own explorative annotations + - total count of explorative annotations in the HTTP header `X-Total-Count` if parameter is set @@ -109,10 +115,13 @@ List the task annotations of a user - return only the first x results (defaults to 1000) - Optional GET parameter `pageNumber=[INT]` - return the results starting at offset `limit` * `pageNumber` (defaults to 0) + - Optional GET parameter `includeTotalCount=[BOOLEAN]` + - if true returns the total count of entries (defaults to false) #### Returns - JSON list of objects containing annotation information about the task annotations of the user, also including task and task type information - + - total count of task annotations in the HTTP header `X-Total-Count` if parameter is set + - total count of task annotations in the HTTP header `X-Total-Count` if parameter is set --- ### `GET /users/:id/annotations` @@ -128,9 +137,12 @@ List the explorative annotations of a uaser - return only the first x results (defaults to 1000) - Optional GET parameter `pageNumber=[INT]` - return the results starting at offset `limit` * `pageNumber` (defaults to 0) + - Optional GET parameter `includeTotalCount=[BOOLEAN]` + - if true returns the total count of entries (defaults to false) #### Returns - JSON list of objects containing annotation information about the explorative annotations of the user + - total count of explorative annotations in the HTTP header `X-Total-Count` if parameter is set @@ -528,9 +540,12 @@ List all tasks of a project - return only the first x results (defaults to infinity) - Optional GET parameter `pageNumber=[INT]` - return the results starting at offset `limit` * `pageNumber` (defaults to 0) + - Optional GET parameter `includeTotalCount=[BOOLEAN]` + - if true returns the total count of entries (defaults to false) #### Returns - JSON list of objects containing task information + - total count of tasks in the HTTP header `X-Total-Count` if parameter is set #### Note - For smoother backwards compatibility, the limit defaults to infinity. However, to ease server load and improve response time, we suggest using a limit of 1000 diff --git a/util/src/main/scala/com/scalableminds/util/tools/Converter.scala b/util/src/main/scala/com/scalableminds/util/tools/Converter.scala index a2e7d0df56a..218ff00d962 100644 --- a/util/src/main/scala/com/scalableminds/util/tools/Converter.scala +++ b/util/src/main/scala/com/scalableminds/util/tools/Converter.scala @@ -46,6 +46,11 @@ object DefaultConverters { } } + implicit object BoolToOption extends Converter[Boolean, Unit] { + override def convert(a: Boolean): Option[Unit] = + if (a) Some(()) else None + } + implicit object IntArrayToByteArrayConverter extends ArrayConverter[Array[Int], Array[Byte]] { def convert(a: Array[Int], bytesPerElement: Int): Array[Byte] = a.flatMap { value =>