diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml index b9f1692be..07bf68e05 100644 --- a/.github/workflows/scala.yml +++ b/.github/workflows/scala.yml @@ -1,25 +1,34 @@ -name: Scala CI +name: MapRoulette Backend CI on: push: pull_request: jobs: + generate_app_secret: + runs-on: ubuntu-latest + outputs: + application_secret: ${{ steps.generate_app_secret.outputs.application_app_secret }} + steps: + - name: Generate Playframework APPLICATION_SECRET + id: generate_app_secret + run: echo "application_app_secret=$(openssl rand -base64 32)" >> "$GITHUB_OUTPUT" + sbt_formatChecks_dependencyTree: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: - java-version: 11 + java-version: 17 distribution: 'temurin' + cache: sbt - name: Create sbt dependencyTree env: CI: true run: | - sbt -Dsbt.log.format=false 'set asciiGraphWidth := 10000' 'dependencyTree' - - name: Verify scalafix passes + sbt -Dsbt.log.format=false 'set asciiGraphWidth := 10000' 'dependencyTree' 'evicted' + - name: Verify code format checks pass env: CI: true run: | @@ -27,9 +36,10 @@ jobs: sbt_tests_jacoco: runs-on: ubuntu-latest + needs: generate_app_secret services: - postgis11: - image: postgis/postgis:13-3.3 + postgis: + image: postgis/postgis:16-3.4 ports: - 5432:5432 env: @@ -38,16 +48,18 @@ jobs: POSTGRES_PASSWORD: osm strategy: matrix: - java: [ 11 ] + java: [ 11, 17 ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: 'temurin' + cache: sbt - name: Run sbt tests with jacoco analysis env: + APPLICATION_SECRET: ${{ needs.generate_app_secret.outputs.application_secret }} CI: true MR_TEST_DB_NAME: "mr_test" MR_TEST_DB_USER: "osm" @@ -57,8 +69,9 @@ jobs: build: runs-on: ubuntu-latest + needs: generate_app_secret services: - postgis11: + postgis: image: postgis/postgis:13-3.3 ports: - 5432:5432 @@ -68,18 +81,20 @@ jobs: POSTGRES_PASSWORD: osm strategy: matrix: - java: [11] + java: [17] steps: - - uses: actions/checkout@v3 - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: repository: 'osmlab/maproulette-java-client' path: 'java-client' - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: + # https://github.com/actions/setup-java?tab=readme-ov-file#install-multiple-jdks java-version: ${{ matrix.java }} distribution: 'temurin' + cache: sbt - name: Run sbt compile env: CI: true @@ -95,6 +110,7 @@ jobs: - name: Run maproulette and the maproulette-java-client integration tests env: # maproulette overrides + APPLICATION_SECRET: ${{ needs.generate_app_secret.outputs.application_secret }} CI: true SBT_OPTS: "-Xms512M -Xmx1024M -Xss2M -XX:MaxMetaspaceSize=1024M" MR_SUPER_KEY: 1234 diff --git a/app/controllers/AuthController.scala b/app/controllers/AuthController.scala index 56c856158..caf5ae1ea 100644 --- a/app/controllers/AuthController.scala +++ b/app/controllers/AuthController.scala @@ -306,7 +306,7 @@ class AuthController @Inject() ( case Some(updated) => updated.apiKey match { case Some(api) => { - Future(storeAPIKeyInOSM(user)) + Future(storeAPIKeyInOSM(updated)) Ok(api) } case None => NoContent diff --git a/app/org/maproulette/framework/controller/TaskBundleController.scala b/app/org/maproulette/framework/controller/TaskBundleController.scala index 579d15826..522210102 100644 --- a/app/org/maproulette/framework/controller/TaskBundleController.scala +++ b/app/org/maproulette/framework/controller/TaskBundleController.scala @@ -237,12 +237,13 @@ class TaskBundleController @Inject() ( */ def createTaskBundle(): Action[JsValue] = Action.async(bodyParsers.json) { implicit request => this.sessionManager.authenticatedRequest { implicit user => - val name = (request.body \ "name").asOpt[String].getOrElse("") + val name = (request.body \ "name").asOpt[String].getOrElse("") + val primaryId = (request.body \ "primaryId").asOpt[Long] val taskIds = (request.body \ "taskIds").asOpt[List[Long]] match { case Some(tasks) => tasks case None => throw new InvalidException("No task ids provided for task bundle") } - val bundle = this.serviceManager.taskBundle.createTaskBundle(user, name, taskIds) + val bundle = this.serviceManager.taskBundle.createTaskBundle(user, name, primaryId, taskIds) Created(Json.toJson(bundle)) } } @@ -253,8 +254,25 @@ class TaskBundleController @Inject() ( * @param id The id for the bundle * @return Task Bundle */ - def getTaskBundle(id: Long): Action[AnyContent] = Action.async { implicit request => + def getTaskBundle(id: Long, lockTasks: Boolean): Action[AnyContent] = Action.async { + implicit request => + this.sessionManager.authenticatedRequest { implicit user => + Ok(Json.toJson(this.serviceManager.taskBundle.getTaskBundle(user, id, lockTasks))) + } + } + + /** + * Resets the bundle to the tasks provided, and unlock all tasks removed from current bundle + * + * @param bundleId The id of the bundle + * @param taskIds The task ids the bundle will reset to + */ + def resetTaskBundle( + id: Long, + taskIds: List[Long] + ): Action[AnyContent] = Action.async { implicit request => this.sessionManager.authenticatedRequest { implicit user => + this.serviceManager.taskBundle.resetTaskBundle(user, id, taskIds) Ok(Json.toJson(this.serviceManager.taskBundle.getTaskBundle(user, id))) } } @@ -266,24 +284,26 @@ class TaskBundleController @Inject() ( * @param taskIds List of task ids to remove * @return Task Bundle */ - def unbundleTasks(id: Long, taskIds: List[Long]): Action[AnyContent] = Action.async { - implicit request => - this.sessionManager.authenticatedRequest { implicit user => - this.serviceManager.taskBundle.unbundleTasks(user, id, taskIds) - Ok(Json.toJson(this.serviceManager.taskBundle.getTaskBundle(user, id))) - } + def unbundleTasks( + id: Long, + taskIds: List[Long], + preventTaskIdUnlocks: List[Long] + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + this.serviceManager.taskBundle.unbundleTasks(user, id, taskIds, preventTaskIdUnlocks) + Ok(Json.toJson(this.serviceManager.taskBundle.getTaskBundle(user, id))) + } } /** * Delete bundle. * * @param id The id for the bundle - * @param primaryId optional task id to no unlock after deleting this bundle */ - def deleteTaskBundle(id: Long, primaryId: Option[Long] = None): Action[AnyContent] = + def deleteTaskBundle(id: Long): Action[AnyContent] = Action.async { implicit request => this.sessionManager.authenticatedRequest { implicit user => - this.serviceManager.taskBundle.deleteTaskBundle(user, id, primaryId) + this.serviceManager.taskBundle.deleteTaskBundle(user, id) Ok } } diff --git a/app/org/maproulette/framework/controller/TaskReviewController.scala b/app/org/maproulette/framework/controller/TaskReviewController.scala index eac57530f..22bd58eb2 100644 --- a/app/org/maproulette/framework/controller/TaskReviewController.scala +++ b/app/org/maproulette/framework/controller/TaskReviewController.scala @@ -272,6 +272,7 @@ class TaskReviewController @Inject() ( */ def extractReviewTableData( taskId: String, + featureId: String, reviewStatus: String, mapper: String, challengeId: String, @@ -303,6 +304,7 @@ class TaskReviewController @Inject() ( val projectIdFilter = parseParameterLong(projectId) val challengeIdsFilter = parseParameterLong(challengeId) val taskIdFilter = parseParameterLong(taskId).map(_.head) + val taskFeatureIdFilter = parseParameterString(featureId).map(_.head) val mappedOnFilter = parseParameterString(mappedOn).map(_.head) val mapperFilter = parseParameterString(mapper).map(_.head) val metaReviewedByFilter = parseParameterString(metaReviewedBy).map(_.head) @@ -318,6 +320,7 @@ class TaskReviewController @Inject() ( ), taskParams = params.taskParams.copy( taskId = taskIdFilter, + taskFeatureId = taskFeatureIdFilter, taskStatus = statusFilter, taskReviewStatus = reviewStatusFilter, taskPriorities = priorityFilter, diff --git a/app/org/maproulette/framework/mixins/ReviewSearchMixin.scala b/app/org/maproulette/framework/mixins/ReviewSearchMixin.scala index c380c66b9..ef2fead6d 100644 --- a/app/org/maproulette/framework/mixins/ReviewSearchMixin.scala +++ b/app/org/maproulette/framework/mixins/ReviewSearchMixin.scala @@ -192,6 +192,7 @@ trait ReviewSearchMixin extends SearchParametersMixin { .addFilterGroup(this.filterLocation(searchParameters)) .addFilterGroup(this.filterProjectSearch(searchParameters)) .addFilterGroup(this.filterTaskId(searchParameters)) + .addFilterGroup(this.filterTaskFeatureId(searchParameters)) .addFilterGroup(this.filterPriority(searchParameters)) .addFilterGroup(this.filterTaskTags(searchParameters)) .addFilterGroup(this.filterReviewDate(searchParameters)) diff --git a/app/org/maproulette/framework/mixins/SearchParametersMixin.scala b/app/org/maproulette/framework/mixins/SearchParametersMixin.scala index 9dad049cd..4e5891431 100644 --- a/app/org/maproulette/framework/mixins/SearchParametersMixin.scala +++ b/app/org/maproulette/framework/mixins/SearchParametersMixin.scala @@ -25,6 +25,7 @@ trait SearchParametersMixin { this.filterBounding(params), this.filterTaskStatus(params), this.filterTaskId(params), + this.filterTaskFeatureId(params), this.filterProjectSearch(params), this.filterTaskReviewStatus(params), this.filterMetaReviewStatus(params), @@ -39,6 +40,7 @@ trait SearchParametersMixin { this.filterChallengeStatus(params), this.filterChallengeRequiresLocal(params), this.filterBoundingGeometries(params), + this.filterBundleId(params), // For efficiency can only query on task properties with a parent challenge id this.filterTaskProps(params), this.filterChallenges(params), @@ -305,6 +307,40 @@ trait SearchParametersMixin { } } + /** + * Filters by tasks.name + * @param params with inverting on 'fid' + */ + def filterTaskFeatureId(params: SearchParameters): FilterGroup = { + params.taskParams.taskFeatureId match { + case Some(fid) => + FilterGroup( + List( + CustomParameter( + s"LOWER(TRIM(${Task.TABLE}.${Task.FIELD_NAME}::TEXT)) LIKE LOWER('%${fid.trim}%')" + ) + ) + ) + case None => FilterGroup(List()) + } + } + + /** + * Filters by bundle id + * @param params with inverting on 'bid' + */ + def filterBundleId(params: SearchParameters): FilterGroup = { + params.taskParams.bundleId match { + case Some(bid) => + FilterGroup( + List( + CustomParameter(s"${Task.TABLE}.${Task.FIELD_BUNDLE_ID} = $bid") + ) + ) + case _ => FilterGroup(List()) + } + } + /** * Filters by tasks.priority * @param params with inverting on 'priorities' diff --git a/app/org/maproulette/framework/model/User.scala b/app/org/maproulette/framework/model/User.scala index 25c7a0f1d..5aa535dd1 100644 --- a/app/org/maproulette/framework/model/User.scala +++ b/app/org/maproulette/framework/model/User.scala @@ -283,6 +283,7 @@ object User extends CommonField { implicit val osmReads: Reads[OSMProfile] = Json.reads[OSMProfile] implicit val searchResultWrites: Writes[UserSearchResult] = Json.writes[UserSearchResult] implicit val projectManagerWrites: Writes[ProjectManager] = Json.writes[ProjectManager] + val logger = LoggerFactory.getLogger(this.getClass) val TABLE = "users" val FIELD_OSM_ID = "osm_id" @@ -416,9 +417,9 @@ object User extends CommonField { user.copy(apiKey = decryptedAPIKey) } catch { case _: BadPaddingException | _: IllegalBlockSizeException => - LoggerFactory - .getLogger(this.getClass) - .debug("Invalid key found, could be that the application secret on server changed.") + logger.debug( + "Invalid key found, could be that the application secret on server changed." + ) user case e: Throwable => throw e } diff --git a/app/org/maproulette/framework/repository/ChallengeRepository.scala b/app/org/maproulette/framework/repository/ChallengeRepository.scala index 55e5c5909..f4d8efdac 100644 --- a/app/org/maproulette/framework/repository/ChallengeRepository.scala +++ b/app/org/maproulette/framework/repository/ChallengeRepository.scala @@ -173,11 +173,12 @@ class ChallengeRepository @Inject() (override val db: Database) extends Reposito SQL( s""" |UPDATE challenges - |SET completion_percentage = new_completion_percentage + |SET completion_percentage = new_completion_percentage, tasks_remaining = new_tasks_remaining |FROM ( | SELECT | challenges.id AS challenge_id, - | completed_task_counts.completed_tasks * 100 / total_task_counts.total_tasks AS new_completion_percentage + | completed_task_counts.completed_tasks * 100 / total_task_counts.total_tasks AS new_completion_percentage, + | total_task_counts.total_tasks - completed_task_counts.completed_tasks AS new_tasks_remaining | FROM | challenges | JOIN ( diff --git a/app/org/maproulette/framework/repository/TaskBundleRepository.scala b/app/org/maproulette/framework/repository/TaskBundleRepository.scala index 8f457fcfc..1e322d7c4 100644 --- a/app/org/maproulette/framework/repository/TaskBundleRepository.scala +++ b/app/org/maproulette/framework/repository/TaskBundleRepository.scala @@ -8,6 +8,7 @@ package org.maproulette.framework.repository import org.slf4j.LoggerFactory import anorm.ToParameterValue +import anorm.SqlParser.scalar import anorm._, postgresql._ import javax.inject.{Inject, Singleton} import org.maproulette.exception.InvalidException @@ -16,6 +17,7 @@ import org.maproulette.framework.psql.Query import org.maproulette.framework.psql.filter.BaseParameter import org.maproulette.framework.model.{Task, TaskBundle, User} import org.maproulette.framework.mixins.{TaskParserMixin, Locking} +import org.maproulette.framework.model.Task.STATUS_CREATED import org.maproulette.data.TaskType import play.api.db.Database @@ -33,7 +35,6 @@ class TaskBundleRepository @Inject() ( with Locking[Task] { protected val logger = LoggerFactory.getLogger(this.getClass) implicit val baseTable: String = Task.TABLE - val cacheManager = this.taskRepository.cacheManager /** * Inserts a new task bundle with the given tasks, assigning ownership of @@ -46,6 +47,7 @@ class TaskBundleRepository @Inject() ( def insert( user: User, name: String, + primaryId: Option[Long], taskIds: List[Long], verifyTasks: (List[Task]) => Unit ): TaskBundle = { @@ -55,6 +57,7 @@ class TaskBundleRepository @Inject() ( } val failedTaskIds = taskIds.diff(lockedTasks.map(_.id)) + // Checks to see if there where any tasks that were locked when the user tried to bundle them. if (failedTaskIds.nonEmpty) { throw new InvalidException( s"Bundle creation failed because the following task IDs were locked: ${failedTaskIds.mkString(", ")}" @@ -63,24 +66,41 @@ class TaskBundleRepository @Inject() ( verifyTasks(lockedTasks) - for (task <- lockedTasks) { - try { - this.lockItem(user, task) - } catch { - case e: Exception => this.logger.warn(e.getMessage) - } - } - val rowId = SQL"""INSERT INTO bundles (owner_id, name) VALUES (${user.id}, ${name})""".executeInsert() + rowId match { - case Some(bundleId) => - val sqlQuery = + case Some(bundleId: Long) => + // Update the task object to bind it to the bundle + SQL(s"""UPDATE tasks SET bundle_id = $bundleId + WHERE id IN ({inList})""") + .on( + "inList" -> taskIds + ) + .executeUpdate() + + primaryId match { + case Some(id) => + val sqlQuery = s"""UPDATE tasks SET is_bundle_primary = true WHERE id = $id""" + SQL(sqlQuery).executeUpdate() + case None => // Handle the case where primaryId is None + } + + val sqlInsertTaskBundles = s"""INSERT INTO task_bundles (task_id, bundle_id) VALUES ({taskId}, $bundleId)""" - val parameters = lockedTasks.map(task => { - Seq[NamedParameter]("taskId" -> task.id) - }) - BatchSql(sqlQuery, parameters.head, parameters.tail: _*).execute() + val parameters = lockedTasks.map(task => Seq[NamedParameter]("taskId" -> task.id)) + BatchSql(sqlInsertTaskBundles, parameters.head, parameters.tail: _*).execute() + + // Lock each of the new tasks to indicate they are part of the bundle + for (task <- lockedTasks) { + try { + this.lockItem(user, task) + } catch { + case e: Exception => this.logger.warn(e.getMessage) + } + taskRepository.cacheManager.cache.remove(task.id) + } + TaskBundle(bundleId, user.id, lockedTasks.map(task => { task.id }), Some(lockedTasks)) @@ -91,13 +111,129 @@ class TaskBundleRepository @Inject() ( } } + /** + * Resets the bundle to the tasks provided, and unlock all tasks removed from current bundle + * + * @param bundleId The id of the bundle + * @param taskIds The task ids the bundle will reset to + */ + def resetTaskBundle( + user: User, + bundleId: Long, + taskIds: List[Long] + ): Unit = { + withMRTransaction { implicit c => + // Retrieve all the task ids currently in the bundle + val currentTaskIds = this + .retrieveTasks( + Query.simple(List(BaseParameter("bundle_id", bundleId, table = Some("tb")))) + ) + .map(_.id) + + // Remove previous tasks from the bundle join table and unlock them if necessary + val tasksToRemove = currentTaskIds.filter(taskId => !taskIds.contains(taskId)) + + if (tasksToRemove.nonEmpty) { + this.unbundleTasks(user, bundleId, tasksToRemove, List.empty) + } + + // Filter for tasks that need to be added back to the bundle. + val tasksToAdd = taskIds.filterNot(currentTaskIds.contains) + + if (tasksToAdd.nonEmpty) { + this.bundleTasks(user, bundleId, tasksToAdd) + } + } + } + + /** + * Adds tasks to a bundle. + * + * @param bundleId The id of the bundle + */ + def bundleTasks( + user: User, + bundleId: Long, + taskIds: List[Long] + ): Unit = { + this.withMRConnection { implicit c => + val sqlQuery = + s"""INSERT INTO task_bundles (bundle_id, task_id) VALUES ($bundleId, {taskId})""" + val parameters = taskIds.map(taskId => Seq[NamedParameter]("taskId" -> taskId)) + + BatchSql(sqlQuery, parameters.head, parameters.tail: _*).execute() + val primaryTaskId = SQL( + """SELECT id FROM tasks WHERE bundle_id = {bundleId} AND is_bundle_primary = true""" + ).on("bundleId" -> bundleId) + .as(scalar[Int].singleOpt) + .getOrElse(0) + + val primaryTaskStatus: Int = SQL("""SELECT status FROM tasks WHERE id = {primaryTaskId}""") + .on("primaryTaskId" -> primaryTaskId) + .as(scalar[Int].singleOpt) + .getOrElse(0) + + SQL( + s"""UPDATE tasks SET bundle_id = {bundleId}, status = $primaryTaskStatus + WHERE bundle_id IS NULL AND id IN ({inList})""" + ).on( + "bundleId" -> bundleId, + "inList" -> taskIds + ) + .executeUpdate() + + val taskReviewQuery = SQL( + """SELECT review_status, review_requested_by FROM task_review WHERE task_id = {primaryTaskId}""" + ).on("primaryTaskId" -> primaryTaskId) + .as((scalar[Int] ~ scalar[Int]).singleOpt) + + taskReviewQuery match { + case Some((primaryTaskReviewStatus ~ primaryTaskReviewRequestedBy)) => + SQL( + """INSERT INTO task_review (task_id, review_status, review_requested_by) + SELECT id, {reviewStatus}, {reviewRequestedBy} + FROM tasks WHERE id IN ({inList})""" + ).on( + "reviewStatus" -> primaryTaskReviewStatus, + "reviewRequestedBy" -> primaryTaskReviewRequestedBy, + "inList" -> taskIds + ) + .executeUpdate() + case None => + } + + val lockedTasks = this.withListLocking(user, Some(TaskType())) { () => + this.taskDAL.retrieveListById(-1, 0)(taskIds) + } + + lockedTasks.foreach { task => + try { + this.lockItem(user, task) + } catch { + case e: Exception => + this.logger.warn(e.getMessage) + } + taskRepository.cacheManager.cache.remove(task.id) + } + } + } + /** * Removes tasks from a bundle. * * @param bundleId The id of the bundle */ - def unbundleTasks(user: User, bundleId: Long, taskIds: List[Long])(): Unit = { + def unbundleTasks( + user: User, + bundleId: Long, + taskIds: List[Long], + preventTaskIdUnlocks: List[Long] + ): Unit = { this.withMRConnection { implicit c => + val tasks = this.retrieveTasks( + Query.simple(List(BaseParameter("bundle_id", bundleId, table = Some("tb")))) + ) + // Unset any bundle_id on individual tasks (this is set when task is completed) SQL(s"""UPDATE tasks SET bundle_id = NULL WHERE bundle_id = {bundleId} @@ -109,27 +245,34 @@ class TaskBundleRepository @Inject() ( ) .executeUpdate() - // Remove task from bundle join table. - val tasks = this.retrieveTasks( - Query.simple( - List( - BaseParameter("bundle_id", bundleId, table = Some("tb")) - ) - ) - ) - for (task <- tasks) { if (!task.isBundlePrimary.getOrElse(false)) { - taskIds.find(id => id == task.id) match { + taskIds.find(_ == task.id) match { case Some(_) => SQL(s"""DELETE FROM task_bundles - WHERE bundle_id = ${bundleId} AND task_id = ${task.id}""").executeUpdate() + WHERE bundle_id = $bundleId AND task_id = ${task.id}""").executeUpdate() + // This is in order to pass the filters so the task is displayed as "available" in task searching and maps. + SQL(s"DELETE FROM task_review tr WHERE tr.task_id = ${task.id}").executeUpdate() + + SQL( + """UPDATE tasks + SET status = {status} + WHERE id = {taskId} + """ + ).on( + "taskId" -> task.id, + "status" -> STATUS_CREATED + ) + .executeUpdate() - try { - this.unlockItem(user, task) - } catch { - case e: Exception => this.logger.warn(e.getMessage) + if (!preventTaskIdUnlocks.contains(task.id)) { + try { + this.unlockItem(user, task) + } catch { + case e: Exception => this.logger.warn(e.getMessage) + } } + taskRepository.cacheManager.cache.remove(task.id) case None => // do nothing } } @@ -142,57 +285,37 @@ class TaskBundleRepository @Inject() ( * * @param bundleId The id of the bundle */ - def deleteTaskBundle(user: User, bundle: TaskBundle, primaryTaskId: Option[Long] = None): Unit = { + def deleteTaskBundle(user: User, bundleId: Long): Unit = { this.withMRConnection { implicit c => + // Retrieve tasks + val tasks = this.retrieveTasks( + Query.simple(List(BaseParameter("bundle_id", bundleId, table = Some("tb")))) + ) + + // Update tasks to set bundle_id and is_bundle_primary to NULL SQL( """UPDATE tasks - SET bundle_id = NULL, - is_bundle_primary = NULL - WHERE bundle_id = {bundleId} OR id = {primaryTaskId}""" - ).on( - Symbol("bundleId") -> bundle.bundleId, - Symbol("primaryTaskId") -> primaryTaskId - ) + SET bundle_id = NULL, + is_bundle_primary = NULL + WHERE bundle_id = {bundleId}""" + ).on("bundleId" -> bundleId) .executeUpdate() - if (primaryTaskId != None) { - // unlock tasks (everything but the primary task id) - val tasks = bundle.tasks match { - case Some(t) => - for (task <- t) { - if (task.id != primaryTaskId.getOrElse(0)) { - try { - this.unlockItem(user, task) - } catch { - case e: Exception => this.logger.warn(e.getMessage) - } - } - } - case None => // no tasks in bundle - } - } - - // Update cache for each task in the bundle - bundle.tasks match { - case Some(t) => - for (task <- t) { - this.cacheManager.withOptionCaching { () => - Some( - task.copy( - bundleId = None, - isBundlePrimary = None - ) - ) - } - } - - case None => // no tasks in bundle - } - // Delete from task_bundles which will also cascade delete from bundles SQL("DELETE FROM task_bundles WHERE bundle_id = {bundleId}") - .on(Symbol("bundleId") -> bundle.bundleId) + .on("bundleId" -> bundleId) .executeUpdate() + + tasks.foreach { task => + if (!task.isBundlePrimary.getOrElse(false)) { + try { + this.unlockItem(user, task) + } catch { + case e: Exception => this.logger.warn(e.getMessage) + } + } + taskRepository.cacheManager.cache.remove(task.id) + } } } @@ -225,4 +348,21 @@ class TaskBundleRepository @Inject() ( """).as(this.getTaskParser(this.taskRepository.updateAndRetrieve).*) } } + + /** + * Locks tasks on bundle fetch if task is in an editable status + * + * @param bundleId The id of the bundle + */ + def lockBundledTasks(user: User, tasks: List[Task]) = { + this.withMRConnection { implicit c => + for (task <- tasks) { + try { + this.lockItem(user, task) + } catch { + case e: Exception => this.logger.warn(e.getMessage) + } + } + } + } } diff --git a/app/org/maproulette/framework/repository/UserRepository.scala b/app/org/maproulette/framework/repository/UserRepository.scala index 58f0379f6..eba79107d 100644 --- a/app/org/maproulette/framework/repository/UserRepository.scala +++ b/app/org/maproulette/framework/repository/UserRepository.scala @@ -5,23 +5,23 @@ package org.maproulette.framework.repository -import java.sql.Connection - import anorm.SqlParser._ import anorm._ -import com.vividsolutions.jts.geom.{Coordinate, GeometryFactory, Point} -import com.vividsolutions.jts.io.WKTReader -import javax.inject.{Inject, Singleton} import org.joda.time.DateTime +import org.locationtech.jts.geom.{Coordinate, GeometryFactory, Point} +import org.locationtech.jts.io.WKTReader import org.maproulette.Config import org.maproulette.framework.model._ -import org.maproulette.framework.service.{ServiceManager, GrantService} import org.maproulette.framework.psql.filter._ import org.maproulette.framework.psql.{Query, SQLUtils} +import org.maproulette.framework.service.{GrantService, ServiceManager} import org.maproulette.models.dal.ChallengeDAL import play.api.db.Database import play.api.libs.json.{JsResultException, Json} +import java.sql.Connection +import javax.inject.{Inject, Singleton} + /** * The User repository handles all the sql queries that are executed against the database for the * User object diff --git a/app/org/maproulette/framework/service/TaskBundleService.scala b/app/org/maproulette/framework/service/TaskBundleService.scala index 7bc2b84a0..318a6c546 100644 --- a/app/org/maproulette/framework/service/TaskBundleService.scala +++ b/app/org/maproulette/framework/service/TaskBundleService.scala @@ -39,11 +39,17 @@ class TaskBundleService @Inject() ( * @param name The name of the task bundle * @param taskIds The tasks to be added to the bundle */ - def createTaskBundle(user: User, name: String, taskIds: List[Long]): TaskBundle = { + def createTaskBundle( + user: User, + name: String, + bundlePrimary: Option[Long], + taskIds: List[Long] + ): TaskBundle = { this.repository.insert( user, name, + bundlePrimary, taskIds, (tasks: List[Task]) => { if (tasks.length < 1) { @@ -83,12 +89,40 @@ class TaskBundleService @Inject() ( ) } + /** + * Resets the bundle to the tasks provided, and unlock all tasks removed from current bundle + * + * @param bundleId The id of the bundle + * @param taskIds The task ids the bundle will reset to + */ + def resetTaskBundle( + user: User, + bundleId: Long, + taskIds: List[Long] + ): TaskBundle = { + val bundle = this.getTaskBundle(user, bundleId) + + if (!permission.isSuperUser(user) && bundle.ownerId != user.id) { + throw new IllegalAccessException( + "Only a super user or the original user can reset this bundle." + ) + } + + this.repository.resetTaskBundle(user, bundleId, taskIds) + this.getTaskBundle(user, bundleId) + } + /** * Removes tasks from a bundle. * * @param bundleId The id of the bundle */ - def unbundleTasks(user: User, bundleId: Long, taskIds: List[Long])(): TaskBundle = { + def unbundleTasks( + user: User, + bundleId: Long, + taskIds: List[Long], + preventTaskIdUnlocks: List[Long] + )(): TaskBundle = { val bundle = this.getTaskBundle(user, bundleId) // Verify permissions to modify this bundle @@ -98,7 +132,7 @@ class TaskBundleService @Inject() ( ) } - this.repository.unbundleTasks(user, bundleId, taskIds) + this.repository.unbundleTasks(user, bundleId, taskIds, preventTaskIdUnlocks) this.getTaskBundle(user, bundleId) } @@ -107,7 +141,7 @@ class TaskBundleService @Inject() ( * * @param bundleId The id of the bundle */ - def deleteTaskBundle(user: User, bundleId: Long, primaryTaskId: Option[Long] = None): Unit = { + def deleteTaskBundle(user: User, bundleId: Long): Unit = { val bundle = this.getTaskBundle(user, bundleId) // Verify permissions to delete this bundle @@ -117,7 +151,7 @@ class TaskBundleService @Inject() ( this.permission.hasObjectWriteAccess(challenge.get, user) } - this.repository.deleteTaskBundle(user, bundle, primaryTaskId) + this.repository.deleteTaskBundle(user, bundle.bundleId) } /** @@ -125,7 +159,7 @@ class TaskBundleService @Inject() ( * * @param bundleId The id of the bundle */ - def getTaskBundle(user: User, bundleId: Long): TaskBundle = { + def getTaskBundle(user: User, bundleId: Long, lockTasks: Boolean = false): TaskBundle = { val filterQuery = Query.simple( List( @@ -136,12 +170,13 @@ class TaskBundleService @Inject() ( val ownerId = this.repository.retrieveOwner(filterQuery) val tasks = this.repository.retrieveTasks(filterQuery) - if (ownerId == None) { - throw new NotFoundException(s"Task Bundle not found with id ${bundleId}.") + if (ownerId.isEmpty) { + throw new NotFoundException(s"Task Bundle not found with id $bundleId.") + } + if (lockTasks) { + this.repository.lockBundledTasks(user, tasks) } - TaskBundle(bundleId, ownerId.get, tasks.map(task => { - task.id - }), Some(tasks)) + TaskBundle(bundleId, ownerId.get, tasks.map(_.id), Some(tasks)) } } diff --git a/app/org/maproulette/models/dal/TaskDAL.scala b/app/org/maproulette/models/dal/TaskDAL.scala index 9c74fa215..84cb7d2d6 100644 --- a/app/org/maproulette/models/dal/TaskDAL.scala +++ b/app/org/maproulette/models/dal/TaskDAL.scala @@ -1063,7 +1063,17 @@ class TaskDAL @Inject() ( case Failure(f) => List.empty } if (mediumPriorityTasks.isEmpty) { - this.getRandomTasks(user, params, limit, Some(Challenge.PRIORITY_LOW), proximityId) + val lowPriorityTasks = Try( + this.getRandomTasks(user, params, limit, Some(Challenge.PRIORITY_LOW), proximityId) + ) match { + case Success(res) => res + case Failure(f) => List.empty + } + if (lowPriorityTasks.isEmpty) { + this.getRandomTasks(user, params, limit, None, proximityId) + } else { + lowPriorityTasks + } } else { mediumPriorityTasks } @@ -1148,8 +1158,12 @@ class TaskDAL @Inject() ( ) priority match { - case Some(p) => appendInWhereClause(whereClause, s"tasks.priority = $p") - case None => //Ignore + case Some(p) => + appendInWhereClause( + whereClause, + s"tasks.priority = $p AND (tasks.completed_by != ${user.id} OR tasks.completed_by IS NULL)" + ) + case None => // Ignore } if (taskTagIds.nonEmpty) { diff --git a/app/org/maproulette/models/dal/mixin/SearchParametersMixin.scala b/app/org/maproulette/models/dal/mixin/SearchParametersMixin.scala index 3a3a8ebb2..ff885bc75 100644 --- a/app/org/maproulette/models/dal/mixin/SearchParametersMixin.scala +++ b/app/org/maproulette/models/dal/mixin/SearchParametersMixin.scala @@ -33,6 +33,7 @@ trait SearchParametersMixin this.paramsBounding(params, whereClause) this.paramsTaskStatus(params, whereClause) this.paramsTaskId(params, whereClause) + this.paramsTaskFeatureId(params, whereClause) this.paramsProjectSearch(params, whereClause) this.paramsTaskReviewStatus(params, whereClause) this.paramsMetaReviewStatus(params, whereClause) @@ -105,6 +106,10 @@ trait SearchParametersMixin this.appendInWhereClause(whereClause, this.filterTaskId(params).sql()) } + def paramsTaskFeatureId(params: SearchParameters, whereClause: StringBuilder): Unit = { + this.appendInWhereClause(whereClause, this.filterTaskFeatureId(params).sql()) + } + def paramsTaskPriorities(params: SearchParameters, whereClause: StringBuilder): Unit = { this.appendInWhereClause(whereClause, this.filterTaskPriorities(params).sql()) } diff --git a/app/org/maproulette/provider/ChallengeProvider.scala b/app/org/maproulette/provider/ChallengeProvider.scala index 5ee518f0a..b9bf5e0d0 100644 --- a/app/org/maproulette/provider/ChallengeProvider.scala +++ b/app/org/maproulette/provider/ChallengeProvider.scala @@ -485,6 +485,13 @@ class ChallengeProvider @Inject() ( "Element type 'way' does not match target type of '" + targetType + "'" ) } + case Some("relation") => + if (targetType != "relation") { + targetTypeFailed = true + throw new InvalidException( + "Element type 'relation' does not match target type of '" + targetType + "'" + ) + } case Some("node") => if (targetType != "node") { targetTypeFailed = true @@ -522,6 +529,60 @@ class ChallengeProvider @Inject() ( List((geom \ "lon").as[Double], (geom \ "lat").as[Double]) } Some(Json.obj("type" -> "LineString", "coordinates" -> points)) + case Some("relation") => + // Function to recursively extract geometries from relations + def extractGeometries(member: JsValue): Option[JsObject] = { + (member \ "type").asOpt[String] match { + case Some("way") => + val points = (member \ "geometry").as[List[JsValue]].map { + geom => + List((geom \ "lon").as[Double], (geom \ "lat").as[Double]) + } + Some(Json.obj("type" -> "LineString", "coordinates" -> points)) + + case Some("node") => + Some( + Json.obj( + "type" -> "Point", + "coordinates" -> List( + (member \ "lon").as[Double], + (member \ "lat").as[Double] + ) + ) + ) + + case Some("relation") => + // If it's another relation, recursively extract geometries from it + val geometries = (member \ "members").as[List[JsValue]].map { + member => + extractGeometries(member) + } + val geometryCollection = Json.obj( + "type" -> "GeometryCollection", + "geometries" -> geometries + ) + + Some(geometryCollection) + + case _ => + None + } + } + + // Extract geometries from each member of the relation + val geometries = (element \ "members").as[List[JsValue]].map { + member => + extractGeometries(member) + } + + // Create a GeometryCollection + val geometryCollection = Json.obj( + "type" -> "GeometryCollection", + "geometries" -> geometries + ) + + Some(geometryCollection) + case Some("node") => Some( Json.obj( diff --git a/app/org/maproulette/provider/osm/objects/VersionedObjects.scala b/app/org/maproulette/provider/osm/objects/VersionedObjects.scala index 6ee54c968..7d2767301 100644 --- a/app/org/maproulette/provider/osm/objects/VersionedObjects.scala +++ b/app/org/maproulette/provider/osm/objects/VersionedObjects.scala @@ -164,7 +164,7 @@ case class VersionedRelation( ) extends VersionedObject { override def toChangeElement(changesetId: Int): Elem = { - + { for (member <- members) yield % Attribute( @@ -176,7 +176,7 @@ case class VersionedRelation( for (tagKV <- tags) yield % Attribute("k", Text(tagKV._1), Attribute("v", Text(tagKV._2), Null)) } - % Attribute( + % Attribute( "visible", Text(visible.toString), Attribute( diff --git a/app/org/maproulette/session/SearchParameters.scala b/app/org/maproulette/session/SearchParameters.scala index bf5042a88..10ef98e30 100644 --- a/app/org/maproulette/session/SearchParameters.scala +++ b/app/org/maproulette/session/SearchParameters.scala @@ -49,6 +49,8 @@ case class SearchTaskParameters( taskSearch: Option[String] = None, taskStatus: Option[List[Int]] = None, taskId: Option[Long] = None, + taskFeatureId: Option[String] = None, + bundleId: Option[Long] = None, taskReviewStatus: Option[List[Int]] = None, taskProperties: Option[Map[String, String]] = None, taskPropertySearchType: Option[String] = None, @@ -245,6 +247,14 @@ object SearchParameters { case Some(c) => Utils.insertIntoJson(updated, "taskId", c, true) case None => updated } + updated = o.taskParams.taskFeatureId match { + case Some(c) => Utils.insertIntoJson(updated, "taskFeatureId", c, true) + case None => updated + } + updated = o.taskParams.bundleId match { + case Some(c) => Utils.insertIntoJson(updated, "bundleId", c, true) + case None => updated + } updated = o.taskParams.taskReviewStatus match { case Some(c) => Utils.insertIntoJson(updated, "taskReviewStatus", c, true) case None => updated @@ -448,6 +458,10 @@ object SearchParameters { }, //taskIds this.getLongParameter(request.getQueryString("tid"), params.taskParams.taskId), + //taskFeatureId + this.getStringParameter(request.getQueryString("fid"), params.taskParams.taskFeatureId), + //bundleId + this.getLongParameter(request.getQueryString("bid"), params.taskParams.bundleId), //taskReviewStatus request.getQueryString("trStatus") match { case Some(v) => Utils.toIntList(v) diff --git a/app/org/maproulette/utils/Crypto.scala b/app/org/maproulette/utils/Crypto.scala index 6cd8b1860..3079ce400 100644 --- a/app/org/maproulette/utils/Crypto.scala +++ b/app/org/maproulette/utils/Crypto.scala @@ -18,7 +18,7 @@ import org.maproulette.Config */ @Singleton class Crypto @Inject() (config: Config) { - val key = config.config.get[String]("play.http.secret.key") + val key: String = config.config.get[String]("maproulette.secret.key") def encrypt(value: String): String = { val cipher: Cipher = Cipher.getInstance("AES/ECB/PKCS5Padding") diff --git a/build.sbt b/build.sbt index 32e33cf26..8080b4f1e 100644 --- a/build.sbt +++ b/build.sbt @@ -82,32 +82,34 @@ libraryDependencies ++= Seq( guice, // NOTE: Be careful upgrading sangria and play-json as binary incompatibilities can break graphql and the entire UI. // See the compatibility matrix here https://github.com/sangria-graphql/sangria-play-json - "org.sangria-graphql" %% "sangria-play-json" % "2.0.1", - "org.sangria-graphql" %% "sangria" % "2.0.1", - "com.typesafe.play" %% "play-json-joda" % "2.8.2", - "com.typesafe.play" %% "play-json" % "2.8.2", - "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test, - "org.scalatestplus" %% "mockito-4-5" % "3.2.12.0" % Test, + "org.sangria-graphql" %% "sangria-play-json-play29" % "2.0.3", + "org.sangria-graphql" %% "sangria" % "4.0.2", + "com.typesafe.play" %% "play-json-joda" % "2.10.5", + "com.typesafe.play" %% "play-json" % "2.10.5", + "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test, + "org.scalatestplus" %% "mockito-4-5" % "3.2.12.0" % Test, // NOTE: The swagger-ui package is used to obtain the static distribution of swagger-ui, the files included at runtime // and are served by the webserver at route '/assets/lib/swagger-ui/'. We have a few customized swagger files in dir // 'public/swagger'. "org.webjars" % "swagger-ui" % "5.10.3", - "org.playframework.anorm" %% "anorm" % "2.6.10", - "org.playframework.anorm" %% "anorm-postgres" % "2.6.10", - "org.postgresql" % "postgresql" % "42.5.0", - "net.postgis" % "postgis-jdbc" % "2021.1.0", - "joda-time" % "joda-time" % "2.12.0", - // TODO(ljdelight): The vividsolutions package was moved to the Eclipse Foundation as LocationTech. - // See the upgrade guide https://github.com/locationtech/jts/blob/master/MIGRATION.md - "com.vividsolutions" % "jts" % "1.13", - "org.wololo" % "jts2geojson" % "0.14.3", + "org.playframework.anorm" %% "anorm" % "2.7.0", + "org.playframework.anorm" %% "anorm-postgres" % "2.7.0", + "org.postgresql" % "postgresql" % "42.7.3", // https://github.com/pgjdbc/pgjdbc/releases + // slf4j-api version 2.0.x and later use the ServiceLoader mechanism, so exclude the slf4j-api from the postgis-jdbc. + // At some point other libraries will need to be updated to use the ServiceLoader mechanism and we can remove this. + // See https://www.slf4j.org/codes.html#ignoredBindings + "net.postgis" % "postgis-jdbc" % "2023.1.0" exclude ("org.slf4j", "slf4j-api"), // https://github.com/postgis/postgis-java/releases + "joda-time" % "joda-time" % "2.12.7", + // https://github.com/locationtech/jts/releases + "org.locationtech.jts" % "jts-core" % "1.19.0", + "org.wololo" % "jts2geojson" % "0.18.1", // https://github.com/bjornharrtell/jts2geojson/releases "org.apache.commons" % "commons-lang3" % "3.12.0", "commons-codec" % "commons-codec" % "1.14", - "com.typesafe.play" %% "play-mailer" % "8.0.1", - "com.typesafe.play" %% "play-mailer-guice" % "8.0.1", - "com.typesafe.akka" %% "akka-cluster-tools" % "2.6.20", - "com.typesafe.akka" %% "akka-cluster-typed" % "2.6.20", - "com.typesafe.akka" %% "akka-slf4j" % "2.6.20", + "com.typesafe.play" %% "play-mailer" % "9.0.0", + "com.typesafe.play" %% "play-mailer-guice" % "9.0.0", + "com.typesafe.akka" %% "akka-cluster-tools" % "2.6.21", + "com.typesafe.akka" %% "akka-cluster-typed" % "2.6.21", + "com.typesafe.akka" %% "akka-slf4j" % "2.6.21", "net.debasishg" %% "redisclient" % "3.42", "com.github.blemale" %% "scaffeine" % "5.2.1", "com.github.tototoshi" %% "scala-csv" % "1.3.10" diff --git a/conf/application.conf b/conf/application.conf index 88bce228b..d2a90bbfe 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -41,7 +41,6 @@ db { password=${?MR_DATABASE_PASSWORD} hikaricp { - connectionTestQuery="SELECT 1" # The database connection pool size can be tweaked based on available system resources and needed throughput. # Increasing this value causes parallel database transactions at the cost of more RAM, more CPU. # Note: @@ -179,6 +178,12 @@ maproulette { publicOrigin="https://maproulette.org" emailFrom="maproulette@example.com" + # The MapRoulette API secret key used to encrypt/decrypt sensitive things from the database, like user API Keys. + # Do not use the default value in production, generate a new key and set it via conf or 'MAPROULETTE_SECRET_KEY' env. + # A secure way to get a distinct key is to run 'openssl rand -base64 32' and set the output as the secret key. + secret.key = "%APPLICATION_SECRET%" + secret.key = ${?MAPROULETTE_SECRET_KEY} + # redirect for OSM frontend="http://127.0.0.1:3000" diff --git a/conf/dev.conf.example b/conf/dev.conf.example index 82eaee65c..987966eff 100644 --- a/conf/dev.conf.example +++ b/conf/dev.conf.example @@ -1,5 +1,11 @@ include "application.conf" +# The Play application secret key. It's okay for localhost dev conf to be public, but not for any public environment. +# A secure way to get a distinct key is to run 'openssl rand -base64 32' and set the output as the secret key. +# Play 2.9 requires a key of at least 32 characters https://github.com/maproulette/maproulette-backend/issues/1117 +play.http.secret.key = "DEVLOCAL_1z8rvducX6AaMTXQl4olw71YHj3MCFpRXXTB73TNnTc=" +play.http.secret.key = ${?APPLICATION_SECRET} + db.default { url="jdbc:postgresql://localhost:5432/mp_dev" url=${?MR_DATABASE_URL} @@ -16,6 +22,12 @@ maproulette { debug=true bootstrap=true + # The MapRoulette API secret key used to encrypt/decrypt sensitive things from the database, like user API Keys. + # Do not use the default value in production, generate a new key and set it via conf or 'MAPROULETTE_SECRET_KEY' env. + # A secure way to get a distinct key is to run 'openssl rand -base64 32' and set the output as the secret key. + secret.key = "DEVLOCAL_Jw8W2PMl434eL85+IRvoT7DA+eNR9a9N3ZK2Gfx4ecs=" + secret.key = ${?MAPROULETTE_SECRET_KEY} + scheduler { startTimeJitterForMinuteTasks = "15 seconds" startTimeJitterForHourTasks = "30 seconds" diff --git a/conf/v2_route/bundle.api b/conf/v2_route/bundle.api index 389f8fb82..567afd2d4 100644 --- a/conf/v2_route/bundle.api +++ b/conf/v2_route/bundle.api @@ -40,8 +40,30 @@ POST /taskBundle @org.maproulette.framework.c # in: path # description: The id of the Task Bundle # required: true +# - name: lockTasks +# in: query +# description: The tasks in the bundle will be locked by the user. ### -GET /taskBundle/:id @org.maproulette.framework.controller.TaskBundleController.getTaskBundle(id:Long) +POST /taskBundle/:id @org.maproulette.framework.controller.TaskBundleController.getTaskBundle(id:Long, lockTasks:Boolean ?= false) +### +# tags: [ Bundle ] +# summary: Resets a Task Bundle +# description: Resets the bundle to the tasks provided, and unlock all tasks removed from current bundle +# responses: +# '200': +# description: Ok with empty body +# '401': +# description: The user is not authorized to make this request +# parameters: +# - name: id +# in: path +# description: The id of the Task Bundle +# required: true +# - name: taskIds +# in: query +# description: The task ids the bundle will reset to +### +POST /taskBundle/:id/reset @org.maproulette.framework.controller.TaskBundleController.resetTaskBundle(id: Long, taskIds: List[Long]) ### # tags: [ Bundle ] # summary: Deletes a Task Bundle @@ -56,10 +78,8 @@ GET /taskBundle/:id @org.maproulette.framework.c # in: path # description: The id of the Task Bundle # required: true -# - name: primaryId -# in: query ### -DELETE /taskBundle/:id @org.maproulette.framework.controller.TaskBundleController.deleteTaskBundle(id:Long, primaryId:Option[Long]) +DELETE /taskBundle/:id @org.maproulette.framework.controller.TaskBundleController.deleteTaskBundle(id:Long) ### # tags: [ Bundle ] # summary: Unbundles tasks from Task Bundle @@ -84,5 +104,9 @@ DELETE /taskBundle/:id @org.maproulette.framework.c # in: query # description: The list of task ids to remove from the bundle # required: true +# - name: preventTaskIdUnlocks +# in: query +# description: The list of task ids to keep locked when removed from bundle +# required: true ### -POST /taskBundle/:id/unbundle @org.maproulette.framework.controller.TaskBundleController.unbundleTasks(id:Long, taskIds:List[Long]) +POST /taskBundle/:id/unbundle @org.maproulette.framework.controller.TaskBundleController.unbundleTasks(id:Long, taskIds:List[Long], preventTaskIdUnlocks:List[Long]) diff --git a/conf/v2_route/review.api b/conf/v2_route/review.api index c9c5ed406..4528afdfc 100644 --- a/conf/v2_route/review.api +++ b/conf/v2_route/review.api @@ -376,7 +376,7 @@ GET /tasks/review/mappers/export @org.maproulette.f # in: query # description: Reviews to equivalent onlySaved. ### -GET /tasks/review/reviewTable/export @org.maproulette.framework.controller.TaskReviewController.extractReviewTableData(taskId: String ?= "", reviewStatus: String ?= "0,1,2,3,4,5,6,7,-1", mapper: String ?= "", challengeId: String ?= "", projectId: String ?= "", mappedOn: String ?= "", reviewedBy: String ?= "", reviewedAt: String ?= "", metaReviewedBy: String ?= "", metaReviewStatus: String ?= "2,0,1,2,3,6", status: String ?= "0,1,2,3,4,5,6,9", priority: String ?= "0,1,2", tagFilter: String ?= "", sortBy: String ?= "mapped_on", direction: String ?= "ASC", displayedColumns: String ?= "Internal Id,Review Status,Mapper,Challenge,Project,Mapped On,Reviewer,Reviewed On,Status,Priority,Actions,Additional Reviewers", invertedFilters: String ?= "", onlySaved: Boolean ?= false) +GET /tasks/review/reviewTable/export @org.maproulette.framework.controller.TaskReviewController.extractReviewTableData(taskId: String ?= "", featureId: String ?= "", reviewStatus: String ?= "0,1,2,3,4,5,6,7,-1", mapper: String ?= "", challengeId: String ?= "", projectId: String ?= "", mappedOn: String ?= "", reviewedBy: String ?= "", reviewedAt: String ?= "", metaReviewedBy: String ?= "", metaReviewStatus: String ?= "2,0,1,2,3,6", status: String ?= "0,1,2,3,4,5,6,9", priority: String ?= "0,1,2", tagFilter: String ?= "", sortBy: String ?= "mapped_on", direction: String ?= "ASC", displayedColumns: String ?= "Internal Id,Review Status,Mapper,Challenge,Project,Mapped On,Reviewer,Reviewed On,Status,Priority,Actions,Additional Reviewers", invertedFilters: String ?= "", onlySaved: Boolean ?= false) ### # tags: [ Review ] # summary: Retrieve a summary of meta-review coverage for reviewers diff --git a/project/build.properties b/project/build.properties index 563a014da..569ab8f74 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1,2 @@ -sbt.version=1.7.2 +# https://github.com/sbt/sbt/releases +sbt.version=1.9.9 diff --git a/project/plugins.sbt b/project/plugins.sbt index d9c312b4b..6dc6c59de 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,7 +2,8 @@ logLevel := Level.Warn addDependencyTreePlugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.18") +// https://github.com/playframework/playframework/releases +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.9.3") addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.2") @@ -19,3 +20,8 @@ addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.3") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.1") + +// https://github.com/sbt/sbt/issues/6997 +ThisBuild / libraryDependencySchemes ++= Seq( + "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always +) diff --git a/test/org/maproulette/framework/mixins/SearchParametersMixinSpec.scala b/test/org/maproulette/framework/mixins/SearchParametersMixinSpec.scala index 5204669c4..50db79b60 100644 --- a/test/org/maproulette/framework/mixins/SearchParametersMixinSpec.scala +++ b/test/org/maproulette/framework/mixins/SearchParametersMixinSpec.scala @@ -156,6 +156,15 @@ class SearchParametersMixinSpec() extends PlaySpec with SearchParametersMixin { } } + "filterTaskFeatureId" should { + "match on task feature id" in { + val params = SearchParameters(taskParams = SearchTaskParameters(taskFeatureId = Some("123"))) + this + .filterTaskFeatureId(params) + .sql() mustEqual s"LOWER(TRIM(tasks.name::TEXT)) LIKE LOWER('%123%')" + } + } + "filterTaskPriorities" should { "match on task priority" in { val params = diff --git a/test/org/maproulette/framework/service/TaskBundleServiceSpec.scala b/test/org/maproulette/framework/service/TaskBundleServiceSpec.scala index 7529a851b..f3eb038b6 100644 --- a/test/org/maproulette/framework/service/TaskBundleServiceSpec.scala +++ b/test/org/maproulette/framework/service/TaskBundleServiceSpec.scala @@ -35,7 +35,12 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame ) val response = - this.service.createTaskBundle(User.superUser, "my bundle", List(task1.id, task2.id)) + this.service.createTaskBundle( + User.superUser, + "my bundle", + Some(task1.id), + List(task1.id, task2.id) + ) response.taskIds.length mustEqual 2 // tasks.bundle_id is NOT set until setTaskStatus is called!!! @@ -51,14 +56,19 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame // Cannot create a bundle with tasks already assigned intercept[InvalidException] { - this.service.createTaskBundle(User.superUser, "my bundle again", List(task1.id, task2.id)) + this.service.createTaskBundle( + User.superUser, + "my bundle again", + Some(task1.id), + List(task1.id, task2.id) + ) } } "not create a task Bundle with no tasks" taggedAs (TaskTag) in { // Cannot create a bundle with no tasks intercept[InvalidException] { - this.service.createTaskBundle(User.superUser, "my bundle again", List()) + this.service.createTaskBundle(User.superUser, "my bundle again", Some(0), List()) } } @@ -93,7 +103,12 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame // Cannot create a bundle with tasks from different challenges intercept[InvalidException] { - this.service.createTaskBundle(User.superUser, "bad bundle", List(task1.id, task2.id)) + this.service.createTaskBundle( + User.superUser, + "bad bundle", + Some(task1.id), + List(task1.id, task2.id) + ) } } @@ -110,7 +125,12 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame ) val bundle = - this.service.createTaskBundle(User.superUser, "my bundle for get", List(task1.id, task2.id)) + this.service.createTaskBundle( + User.superUser, + "my bundle for get", + Some(task1.id), + List(task1.id, task2.id) + ) val response = this.service.getTaskBundle(User.superUser, bundle.bundleId) response.bundleId mustEqual bundle.bundleId @@ -130,7 +150,12 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame ) val bundle = this.service - .createTaskBundle(User.superUser, "my bundle for delete", List(task1.id, task2.id)) + .createTaskBundle( + User.superUser, + "my bundle for delete", + Some(task1.id), + List(task1.id, task2.id) + ) // tasks.bundle_id is NOT set until setTaskStatus is called taskDAL.setTaskStatus( @@ -159,7 +184,12 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame ) val bundle = this.service - .createTaskBundle(User.superUser, "my bundle for delete", List(task1.id, task2.id)) + .createTaskBundle( + User.superUser, + "my bundle for delete", + Some(task1.id), + List(task1.id, task2.id) + ) // tasks.bundle_id is NOT set until setTaskStatus is called taskDAL.setTaskStatus( @@ -193,7 +223,12 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame ) val bundle = this.service - .createTaskBundle(User.superUser, "my bundle for unbundle", List(task1.id, task2.id)) + .createTaskBundle( + User.superUser, + "my bundle for unbundle", + Some(task1.id), + List(task1.id, task2.id) + ) // tasks.bundle_id is NOT set until setTaskStatus is called taskDAL.setTaskStatus( @@ -204,7 +239,7 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame primaryTaskId = Some(task1.id) ) - this.service.unbundleTasks(User.superUser, bundle.bundleId, List(task2.id))() + this.service.unbundleTasks(User.superUser, bundle.bundleId, List(task2.id), List(task1.id))() val response = this.service.getTaskBundle(User.superUser, bundle.bundleId) response.taskIds.length mustEqual 1 response.taskIds.head mustEqual task1.id @@ -223,7 +258,12 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame ) val bundle = this.service - .createTaskBundle(User.superUser, "my bundle for unbundle", List(task1.id, task2.id)) + .createTaskBundle( + User.superUser, + "my bundle for unbundle", + Some(task1.id), + List(task1.id, task2.id) + ) // tasks.bundle_id is NOT set until setTaskStatus is called taskDAL.setTaskStatus( @@ -241,7 +281,7 @@ class TaskBundleServiceSpec(implicit val application: Application) extends Frame // Random user is not allowed to delete this bundle an[IllegalAccessException] should be thrownBy - this.service.unbundleTasks(randomUser, bundle.bundleId, List(task2.id))() + this.service.unbundleTasks(randomUser, bundle.bundleId, List(task2.id), List())() } }