diff --git a/app/controllers/AdminController.scala b/app/controllers/AdminController.scala index 85ee876f93..4e62bdccea 100644 --- a/app/controllers/AdminController.scala +++ b/app/controllers/AdminController.scala @@ -619,4 +619,54 @@ class AdminController @Inject() (implicit val env: Environment[User, SessionAuth Future.failed(new AuthenticationException("User is not an administrator")) } } + + /** + * Updates the flags of all tasks before the given date for the given user. + */ + def setTaskFlagsBeforeDate() = UserAwareAction.async(BodyParsers.parse.json) { implicit request => + val submission = request.body.validate[TaskFlagsByDateSubmission] + + submission.fold( + errors => { + Future.successful(BadRequest(Json.obj("status" -> "Error", "message" -> JsError.toFlatJson(errors)))) + }, + submission => { + if (isAdmin(request.identity)) { + UserTable.find(submission.username) match { + case Some(user) => + val userId: UUID = UUID.fromString(user.userId) + val date: Timestamp = new Timestamp(submission.date) + AuditTaskTable.updateTaskFlagsBeforeDate(userId, date, submission.flag, submission.state) + Future.successful(Ok(Json.obj("userId" -> userId, "date" -> date, "flag" -> submission.flag, "state" -> submission.state))) + case None => + Future.successful(BadRequest("No user has this user ID")) + } + } else { + Future.failed(new AuthenticationException("User is not an administrator")) + } + } + ) + } + + /** + * Updates a single flag for a single audit task specified by the audit task id. + * @return + */ + def setTaskFlag() = UserAwareAction.async(BodyParsers.parse.json) { implicit request => + val submission = request.body.validate[TaskFlagSubmission] + + submission.fold( + errors => { + Future.successful(BadRequest(Json.obj("status" -> "Error", "message" -> JsError.toFlatJson(errors)))) + }, + submission => { + if (isAdmin(request.identity)) { + AuditTaskTable.updateTaskFlag(submission.auditTaskId, submission.flag, submission.state) + Future.successful(Ok(Json.obj("auditTaskId" -> submission.auditTaskId, "flag" -> submission.flag, "state" -> submission.state))) + } else { + Future.failed(new AuthenticationException("User is not an administrator")) + } + } + ) + } } diff --git a/app/controllers/TaskController.scala b/app/controllers/TaskController.scala index 1cc27ed820..ec223051aa 100644 --- a/app/controllers/TaskController.scala +++ b/app/controllers/TaskController.scala @@ -124,12 +124,13 @@ class TaskController @Inject() (implicit val env: Environment[User, SessionAuthe val auditTaskObj: AuditTask = user match { case Some(user) => AuditTask(0, amtAssignmentId, user.userId.toString, auditTask.streetEdgeId, new Timestamp(auditTask.taskStart), timestamp, completed=false, auditTask.currentLat, auditTask.currentLng, - auditTask.startPointReversed, Some(missionId), auditTask.currentMissionStart) + auditTask.startPointReversed, Some(missionId), auditTask.currentMissionStart, lowQuality=false, + incomplete=false, stale=false) case None => val user: Option[DBUser] = UserTable.find("anonymous") AuditTask(0, amtAssignmentId, user.get.userId, auditTask.streetEdgeId, new Timestamp(auditTask.taskStart), timestamp, completed=false, auditTask.currentLat, auditTask.currentLng, auditTask.startPointReversed, - Some(missionId), auditTask.currentMissionStart) + Some(missionId), auditTask.currentMissionStart, lowQuality=false, incomplete=false, stale=false) } AuditTaskTable.save(auditTaskObj) } diff --git a/app/formats/AdminUpdateSubmissionFormats.scala b/app/formats/AdminUpdateSubmissionFormats.scala index 58ac3ef732..7b1169b6ec 100644 --- a/app/formats/AdminUpdateSubmissionFormats.scala +++ b/app/formats/AdminUpdateSubmissionFormats.scala @@ -6,6 +6,10 @@ import play.api.libs.functional.syntax._ object AdminUpdateSubmissionFormats { case class UserRoleSubmission(userId: String, roleId: String) case class UserOrgSubmission(userId: String, orgId: Int) + case class TaskFlagsByDateSubmission(username: String, date: Long, flag: String, state: Boolean) + case class TaskFlagSubmission(auditTaskId: Int, flag: String, state: Boolean) { + require(flag == "low_quality" || flag == "incomplete" || flag == "stale") + } implicit val userRoleSubmissionReads: Reads[UserRoleSubmission] = ( (JsPath \ "user_id").read[String] and @@ -16,4 +20,17 @@ object AdminUpdateSubmissionFormats { (JsPath \ "user_id").read[String] and (JsPath \ "org_id").read[Int] )(UserOrgSubmission.apply _) + + implicit val taskFlagsByDateSubmissionReads: Reads[TaskFlagsByDateSubmission] = ( + (JsPath \ "username").read[String] and + (JsPath \ "date").read[Long] and + (JsPath \ "flag").read[String] and + (JsPath \ "state").read[Boolean] + )(TaskFlagsByDateSubmission.apply _) + + implicit val taskFlagSubmissionReads: Reads[TaskFlagSubmission] = ( + (JsPath \ "auditTaskId").read[Int] and + (JsPath \ "flag").read[String] and + (JsPath \ "state").read[Boolean] + )(TaskFlagSubmission.apply _) } \ No newline at end of file diff --git a/app/formats/json/LabelFormat.scala b/app/formats/json/LabelFormat.scala index 1973621bd1..1752f9de89 100644 --- a/app/formats/json/LabelFormat.scala +++ b/app/formats/json/LabelFormat.scala @@ -123,6 +123,9 @@ object LabelFormat { "num_disagree" -> labelMetadata.validations("disagree"), "num_notsure" -> labelMetadata.validations("notsure"), "tags" -> labelMetadata.tags, + "low_quality" -> labelMetadata.lowQualityIncompleteStaleFlags._1, + "incomplete" -> labelMetadata.lowQualityIncompleteStaleFlags._2, + "stale" -> labelMetadata.lowQualityIncompleteStaleFlags._3, // The part below is just lifted straight from Admin Validate without much care. "admin_data" -> Json.obj( "username" -> adminData.username, diff --git a/app/formats/json/TaskFormats.scala b/app/formats/json/TaskFormats.scala index 1f990b5aae..8f48d7159e 100644 --- a/app/formats/json/TaskFormats.scala +++ b/app/formats/json/TaskFormats.scala @@ -29,7 +29,10 @@ object TaskFormats { (__ \ "current_lng").write[Float] and (__ \ "start_point_reversed").write[Boolean] and (__ \ "current_mission_id").writeNullable[Int] and - (__ \ "current_mission_start").writeNullable[Point] + (__ \ "current_mission_start").writeNullable[Point] and + (__ \ "low_quality").write[Boolean] and + (__ \ "incomplete").write[Boolean] and + (__ \ "stale").write[Boolean] )(unlift(AuditTask.unapply _)) implicit val auditTaskInteractionWrites: Writes[AuditTaskInteraction] = ( diff --git a/app/models/attribute/UserClusteringSessionTable.scala b/app/models/attribute/UserClusteringSessionTable.scala index 16395679b9..0c9b0e6530 100644 --- a/app/models/attribute/UserClusteringSessionTable.scala +++ b/app/models/attribute/UserClusteringSessionTable.scala @@ -5,6 +5,7 @@ import models.label.{LabelTable, LabelTypeTable} import models.mission.MissionTable import models.region.RegionTable import models.user.UserStatTable +import models.audit.AuditTaskTable import models.utils.MyPostgresDriver.simple._ import play.api.Play.current import play.api.db.slick @@ -77,10 +78,11 @@ object UserClusteringSessionTable { _region <- RegionTable.regions if _mission.regionId === _region.regionId _userStat <- UserStatTable.userStats if _mission.userId === _userStat.userId _lab <- LabelTable.labels if _lab.missionId === _mission.missionId + _task <- AuditTaskTable.auditTasks if _lab.auditTaskId === _task.auditTaskId _latlng <- LabelTable.labelPoints if _lab.labelId === _latlng.labelId _type <- LabelTable.labelTypes if _lab.labelTypeId === _type.labelTypeId if _region.deleted === false - if _lab.correct || (_userStat.highQuality && _lab.correct.isEmpty) + if _lab.correct || (_userStat.highQuality && _lab.correct.isEmpty && !_task.lowQuality) if _latlng.lat.isDefined && _latlng.lng.isDefined } yield (_mission.userId, _lab.labelId, _type.labelType, _latlng.lat.get, _latlng.lng.get, _lab.severity, _lab.temporary) diff --git a/app/models/audit/AuditTaskTable.scala b/app/models/audit/AuditTaskTable.scala index 7fbb90dbc5..d98f87456f 100644 --- a/app/models/audit/AuditTaskTable.scala +++ b/app/models/audit/AuditTaskTable.scala @@ -20,7 +20,8 @@ import scala.slick.jdbc.{GetResult, StaticQuery => Q} case class AuditTask(auditTaskId: Int, amtAssignmentId: Option[Int], userId: String, streetEdgeId: Int, taskStart: Timestamp, taskEnd: Timestamp, completed: Boolean, currentLat: Float, currentLng: Float, - startPointReversed: Boolean, currentMissionId: Option[Int], currentMissionStart: Option[Point]) + startPointReversed: Boolean, currentMissionId: Option[Int], currentMissionStart: Option[Point], + lowQuality: Boolean, incomplete: Boolean, stale: Boolean) case class NewTask(edgeId: Int, geom: LineString, currentLng: Float, currentLat: Float, wayType: String, // OSM road type (residential, trunk, etc.). @@ -96,8 +97,11 @@ class AuditTaskTable(tag: slick.lifted.Tag) extends Table[AuditTask](tag, "audit def startPointReversed = column[Boolean]("start_point_reversed", O.NotNull) def currentMissionId = column[Option[Int]]("current_mission_id", O.Nullable) def currentMissionStart = column[Option[Point]]("current_mission_start", O.Nullable) + def lowQuality = column[Boolean]("low_quality", O.NotNull) + def incomplete = column[Boolean]("incomplete", O.NotNull) + def stale = column[Boolean]("stale", O.NotNull) - def * = (auditTaskId, amtAssignmentId, userId, streetEdgeId, taskStart, taskEnd, completed, currentLat, currentLng, startPointReversed, currentMissionId, currentMissionStart) <> ((AuditTask.apply _).tupled, AuditTask.unapply) + def * = (auditTaskId, amtAssignmentId, userId, streetEdgeId, taskStart, taskEnd, completed, currentLat, currentLng, startPointReversed, currentMissionId, currentMissionStart, lowQuality, incomplete, stale) <> ((AuditTask.apply _).tupled, AuditTask.unapply) def streetEdge: ForeignKeyQuery[StreetEdgeTable, StreetEdge] = foreignKey("audit_task_street_edge_id_fkey", streetEdgeId, TableQuery[StreetEdgeTable])(_.streetEdgeId) @@ -117,7 +121,8 @@ object AuditTaskTable { implicit val auditTaskConverter = GetResult[AuditTask](r => { AuditTask(r.nextInt, r.nextIntOption, r.nextString, r.nextInt, r.nextTimestamp, r.nextTimestamp, r.nextBoolean, - r.nextFloat, r.nextFloat, r.nextBoolean, r.nextIntOption, r.nextGeometryOption[Point]) + r.nextFloat, r.nextFloat, r.nextBoolean, r.nextIntOption, r.nextGeometryOption[Point], r.nextBoolean, + r.nextBoolean, r.nextBoolean) }) implicit val newTaskConverter = GetResult[NewTask](r => { @@ -506,4 +511,44 @@ object AuditTaskTable { val q = for { t <- auditTasks if t.auditTaskId === auditTaskId } yield (t.taskEnd, t.currentLat, t.currentLng, t.currentMissionId, t.currentMissionStart) q.update((timestamp, lat, lng, Some(missionId), currMissionStart)) } + + /** + * Update a single task's flag given the flag type and the status to change to. + * @param auditTaskId + * @param flag One of "low_quality", "incomplete", or "stale". + * @param state + * @return Number of rows updated. + */ + def updateTaskFlag(auditTaskId: Int, flag: String, state: Boolean): Int = db.withSession { implicit session => + val q = for { + t <- auditTasks if t.auditTaskId === auditTaskId + } yield (flag match { + case "low_quality" => t.lowQuality + case "incomplete" => t.incomplete + case "stale" => t.stale + }) + + q.update(state) + } + + /** + * Update all flags of a single type for tasks starting before a specified date. + * @param userId + * @param date + * @param flag One of "low_quality", "incomplete", or "stale". + * @param state + * @return Number of rows updated. + */ + def updateTaskFlagsBeforeDate(userId: UUID, date: Timestamp, flag: String, state: Boolean): Int = db.withSession { implicit session => + require(flag == "low_quality" || flag == "incomplete" || flag == "stale") + val q = for { + t <- auditTasks if t.userId === userId.toString && t.taskStart < date + } yield (flag match { + case "low_quality" => t.lowQuality + case "incomplete" => t.incomplete + case "stale" => t.stale + }) + + q.update(state) + } } diff --git a/app/models/label/LabelTable.scala b/app/models/label/LabelTable.scala index 31cf2b9f1d..3d3b917da0 100644 --- a/app/models/label/LabelTable.scala +++ b/app/models/label/LabelTable.scala @@ -163,14 +163,14 @@ object LabelTable { userId: String, username: String, timestamp: java.sql.Timestamp, labelTypeKey: String, labelTypeValue: String, severity: Option[Int], temporary: Boolean, description: Option[String], userValidation: Option[Int], validations: Map[String, Int], - tags: List[String]) + tags: List[String], lowQualityIncompleteStaleFlags: (Boolean, Boolean, Boolean)) implicit val labelMetadataWithValidationConverter = GetResult[LabelMetadata](r => LabelMetadata( r.nextInt, r.nextString, r.nextBoolean, r.nextString, POV(r.nextDouble, r.nextDouble, r.nextInt), LocationXY(r.nextInt, r.nextInt), r.nextInt, r.nextInt, r.nextInt, r.nextString, r.nextString, r.nextTimestamp, r.nextString, r.nextString, r.nextIntOption, r.nextBoolean, r.nextStringOption, r.nextIntOption, r.nextString.split(',').map(x => x.split(':')).map { y => (y(0), y(1).toInt) }.toMap, - r.nextString.split(",").filter(_.nonEmpty).toList + r.nextString.split(",").filter(_.nonEmpty).toList, (r.nextBoolean, r.nextBoolean, r.nextBoolean) ) ) @@ -492,7 +492,10 @@ object LabelTable { | lb_big.description, | lb_big.validation_result, | val.val_counts, - | array_to_string(lb_big.tags, ',') + | array_to_string(lb_big.tags, ','), + | at.low_quality, + | at.incomplete, + | at.stale |FROM label AS lb1, | gsv_data, | audit_task AS at, @@ -645,12 +648,12 @@ object LabelTable { |-- marked as high quality. Up to 100 more points (100 / (1 + abs(agree_count - disagree_count))) depending |-- on how far we are from consensus. Another 25 points if the label was added in the past week. Then add a |-- random number so that the max score for each label is 276. - |ORDER BY CASE WHEN COALESCE(needs_validations, TRUE) AND label.correct IS NULL THEN 100 ELSE 0 END + + |ORDER BY CASE WHEN COALESCE(needs_validations, TRUE) AND label.correct IS NULL AND NOT audit_task.low_quality AND NOT audit_task.stale THEN 100 ELSE 0 END + | CASE WHEN user_stat.high_quality THEN 50 ELSE 0 END + | 100.0 / (1 + abs(label.agree_count - label.disagree_count)) + | CASE WHEN label.time_created > now() - INTERVAL '1 WEEK' THEN 25 ELSE 0 END + | RANDOM() * (276 - ( - | CASE WHEN COALESCE(needs_validations, TRUE) AND label.correct IS NULL THEN 100 ELSE 0 END + + | CASE WHEN COALESCE(needs_validations, TRUE) AND label.correct IS NULL AND NOT audit_task.low_quality AND NOT audit_task.stale THEN 100 ELSE 0 END + | CASE WHEN user_stat.high_quality THEN 50 ELSE 0 END + | 100.0 / (1 + abs(label.agree_count - label.disagree_count)) + | CASE WHEN label.time_created > now() - INTERVAL '1 WEEK' THEN 25 ELSE 0 END diff --git a/app/models/street/StreetEdgePriorityTable.scala b/app/models/street/StreetEdgePriorityTable.scala index 13c3f3fdfa..aa94de496f 100644 --- a/app/models/street/StreetEdgePriorityTable.scala +++ b/app/models/street/StreetEdgePriorityTable.scala @@ -203,10 +203,12 @@ object StreetEdgePriorityTable { // frequency above our threshold. // NOTE We are calling the getQualityOfUsers function below, which does the heavy lifting. val completions = AuditTaskTable.completedTasks - .groupBy(task => (task.streetEdgeId, task.userId)).map(_._1) // select distinct on street edge id and user id + // Select distinct street edge ids, and keep user id and street flags + .groupBy(task => (task.streetEdgeId, task.userId, task.lowQuality, task.incomplete, task.stale)).map(_._1) .innerJoin(UserStatTable.getQualityOfUsers).on(_._2 === _._1) // join on user_id .filterNot(_._2._3) // filter out users marked with excluded = TRUE - .map { case (_task, _qual) => (_task._1, _qual._2) } // SELECT street_edge_id, is_good_user + // SELECT street_edge_id, (is_good_user AND NOT (low_quality or incomplete or stale)) + .map { case (_task, _qual) => (_task._1, (_qual._2 && !(_task._3 || _task._4 || _task._5))) } /********** Compute Audit Counts **********/ diff --git a/app/views/admin/user.scala.html b/app/views/admin/user.scala.html index 92b1434499..9544dfc2cb 100644 --- a/app/views/admin/user.scala.html +++ b/app/views/admin/user.scala.html @@ -77,6 +77,56 @@

General Info and Stats

+

Mark Work Quality by Date

+

+ Select a date to mark all labels for this user before that date with the desired flag. This is useful when + training new users on Project Sidewalk. If their work was low quality, set it as such below. Their labels + will then be partially hidden in our APIs, and we won't assign other to validate those labels as frequently. +

+

+ If their work was high quality, but they did not use all the available label types or didn't label the + entire street, you can mark their data as "incomplete". Their labels will show up in our APIs, but we'll + have other users re-audit those streets. +

+
+
+

Low Quality

+
+
+ + + + +
+
+
+ + +
+
+ +
+
+
+

Incomplete

+
+
+ + + + +
+
+
+ + +
+
+ +
+
+
+

Labels

@@ -193,10 +243,14 @@

Feedback

updateTimestamps("@lang.code"); + + + +