diff --git a/app/controllers/ApplicationController.scala b/app/controllers/ApplicationController.scala index de02cbbc6e..6017fac7c3 100644 --- a/app/controllers/ApplicationController.scala +++ b/app/controllers/ApplicationController.scala @@ -442,6 +442,21 @@ class ApplicationController @Inject() (implicit val env: Environment[User, Sessi } } + /** + * Returns a page that allows a user to build a custom audit route. + */ + def routeBuilder = UserAwareAction.async { implicit request => + request.identity match { + case Some(user) => + val timestamp: Timestamp = new Timestamp(Instant.now.toEpochMilli) + val ipAddress: String = request.remoteAddress + WebpageActivityTable.save(WebpageActivity(0, user.userId.toString, ipAddress, "Visit_RouteBuilder", timestamp)) + Future.successful(Ok(views.html.routeBuilder(Some(user)))) + case None => + Future.successful(Redirect("/anonSignUp?url=/routeBuilder")) + } + } + /** * Returns the demo page that contains a cool visualization that is a work-in-progress. */ diff --git a/app/controllers/AuditController.scala b/app/controllers/AuditController.scala index d446b0c53a..df9799df09 100644 --- a/app/controllers/AuditController.scala +++ b/app/controllers/AuditController.scala @@ -8,7 +8,6 @@ import com.mohiva.play.silhouette.api.{Environment, Silhouette} import com.mohiva.play.silhouette.impl.authenticators.SessionAuthenticator import com.vividsolutions.jts.geom._ import controllers.headers.ProvidesHeader -import formats.json.IssueFormats._ import formats.json.CommentSubmissionFormats._ import models.amt.AMTAssignmentTable import models.audit._ @@ -16,7 +15,8 @@ import models.daos.slick.DBTableDefinitions.{DBUser, UserTable} import models.label.LabelTable import models.mission.{Mission, MissionSetProgress, MissionTable, MissionTypeTable} import models.region._ -import models.street.{StreetEdgeIssue, StreetEdgeIssueTable, StreetEdgeRegionTable} +import models.route.{Route, RouteTable, UserRoute, UserRouteTable} +import models.street.StreetEdgeRegionTable import models.user._ import play.api.libs.json._ import play.api.{Logger, Play} @@ -42,7 +42,7 @@ class AuditController @Inject() (implicit val env: Environment[User, SessionAuth /** * Returns an audit page. */ - def audit(nextRegion: Option[String], retakeTutorial: Option[Boolean]) = UserAwareAction.async { implicit request => + def audit(newRegion: Boolean, retakeTutorial: Option[Boolean], routeId: Option[Int], resumeRoute: Boolean) = UserAwareAction.async { implicit request => val timestamp: Timestamp = new Timestamp(Instant.now.toEpochMilli) val ipAddress: String = request.remoteAddress @@ -56,87 +56,94 @@ class AuditController @Inject() (implicit val env: Environment[User, SessionAuth UserCurrentRegionTable.delete(user.userId) } - // Get current region if we aren't assigning new one; otherwise assign new region. - var region: Option[Region] = nextRegion match { - case Some("easy") => // Assign an easy region if the query string has nextRegion=easy. - UserCurrentRegionTable.assignEasyRegion(user.userId) - case Some("regular") => // Assign any region if nextRegion=regular and the user is experienced. + // Check if user has an active route or create a new one if routeId was supplied. If resumeRoute is false and no + // routeId was supplied, then the function should return None and the user is not sent on a specific route. + val userRoute: Option[UserRoute] = UserRouteTable.setUpPossibleUserRoute(routeId, user.userId, resumeRoute) + val route: Option[Route] = userRoute.flatMap(ur => RouteTable.getRoute(ur.routeId)) + + // If user is on a specific route, assign them to the correct region. If they have no region assigned or + // newRegion is set to true, assign a new region. Otherwise, get their previously assigned region. + var region: Option[Region] = + if (route.isDefined) { + val regionId: Int = UserCurrentRegionTable.saveOrUpdate(user.userId, route.get.regionId) + RegionTable.getRegion(regionId) + } else if (newRegion || !UserCurrentRegionTable.isAssigned(user.userId)) { UserCurrentRegionTable.assignRegion(user.userId) - case Some(illformedString) => // Log warning, assign new region if one is not already assigned. - Logger.warn(s"Parameter to audit must be \'easy\' or \'regular\', but \'$illformedString\' was passed.") - if (UserCurrentRegionTable.isAssigned(user.userId)) RegionTable.getCurrentRegion(user.userId) - else UserCurrentRegionTable.assignRegion(user.userId) - case None => // Assign new region if one is not already assigned. - if (UserCurrentRegionTable.isAssigned(user.userId)) RegionTable.getCurrentRegion(user.userId) - else UserCurrentRegionTable.assignRegion(user.userId) - } + } else { + RegionTable.getCurrentRegion(user.userId) + } + + // Log visit to the Explore page. + val activityStr: String = + if (route.isDefined) s"Visit_Audit_Route=${route.get.routeId}" + else if (newRegion) "Visit_Audit_NewRegionSelected" + else "Visit_Audit" + WebpageActivityTable.save(WebpageActivity(0, user.userId.toString, ipAddress, activityStr, timestamp)) // Check if a user still has tasks available in this region. This also should never really happen. - if (region.isEmpty || !AuditTaskTable.isTaskAvailable(user.userId, region.get.regionId)) { + if (route.isDefined && region.isEmpty) { + Logger.error("Unable to assign a region for the route.") + } else if (region.isEmpty || !AuditTaskTable.isTaskAvailable(user.userId, region.get.regionId)) { region = UserCurrentRegionTable.assignRegion(user.userId) + } else if (region.isEmpty) { + Logger.error("Unable to assign a region to a user.") // This should _really_ never happen. } - // This should _really_ never happen. - if (region.isEmpty) { - Logger.error("Unable to assign a region to a user.") - } - - nextRegion match { - case Some("easy") | Some("regular") => - WebpageActivityTable.save(WebpageActivity(0, user.userId.toString, ipAddress, "Visit_Audit_NewRegionSelected", timestamp)) - Future.successful(Redirect("/audit")) - case Some(illformedString) => - Future.successful(Redirect("/audit")) - case None => - WebpageActivityTable.save(WebpageActivity(0, user.userId.toString, ipAddress, "Visit_Audit", timestamp)) - val regionId: Int = region.get.regionId - - val role: String = user.role.getOrElse("") - val payPerMeter: Double = if (role == "Turker") AMTAssignmentTable.TURKER_PAY_PER_METER else AMTAssignmentTable.VOLUNTEER_PAY - val tutorialPay: Double = - if (retakingTutorial || role != "Turker") AMTAssignmentTable.VOLUNTEER_PAY - else AMTAssignmentTable.TURKER_TUTORIAL_PAY - - val missionSetProgress: MissionSetProgress = - if (role == "Turker") MissionTable.getProgressOnMissionSet(user.username) - else MissionTable.defaultAuditMissionSetProgress - val mission: Mission = - if (retakingTutorial) MissionTable.resumeOrCreateNewAuditOnboardingMission(user.userId, tutorialPay).get - else MissionTable.resumeOrCreateNewAuditMission(user.userId, regionId, payPerMeter, tutorialPay).get + val regionId: Int = region.get.regionId + + val role: String = user.role.getOrElse("") + val payPerMeter: Double = if (role == "Turker") AMTAssignmentTable.TURKER_PAY_PER_METER else AMTAssignmentTable.VOLUNTEER_PAY + val tutorialPay: Double = + if (retakingTutorial || role != "Turker") AMTAssignmentTable.VOLUNTEER_PAY + else AMTAssignmentTable.TURKER_TUTORIAL_PAY + + val missionSetProgress: MissionSetProgress = + if (role == "Turker") MissionTable.getProgressOnMissionSet(user.username) + else MissionTable.defaultAuditMissionSetProgress + + var mission: Mission = + if (retakingTutorial) MissionTable.resumeOrCreateNewAuditOnboardingMission(user.userId, tutorialPay).get + else MissionTable.resumeOrCreateNewAuditMission(user.userId, regionId, payPerMeter, tutorialPay).get + + // If there is a partially completed task in this route or mission, get that, o/w make a new one. + val task: Option[NewTask] = + if (MissionTypeTable.missionTypeIdToMissionType(mission.missionTypeId) == "auditOnboarding") { + Some(AuditTaskTable.getATutorialTask(mission.missionId)) + } else if (route.isDefined) { + UserRouteTable.getRouteTask(userRoute.get, mission.missionId) + } else if (mission.currentAuditTaskId.isDefined) { + val currTask: Option[NewTask] = AuditTaskTable.selectTaskFromTaskId(mission.currentAuditTaskId.get) + // If we found no task with the given ID, try to get any new task in the neighborhood. + if (currTask.isDefined) currTask + else AuditTaskTable.selectANewTaskInARegion(regionId, user.userId, mission.missionId) + } else { + AuditTaskTable.selectANewTaskInARegion(regionId, user.userId, mission.missionId) + } + val nextTempLabelId: Int = LabelTable.nextTempLabelId(user.userId) - // If there is a partially completed task in this mission, get that, o/w make a new one. - val task: Option[NewTask] = - if (MissionTypeTable.missionTypeIdToMissionType(mission.missionTypeId) == "auditOnboarding") - Some(AuditTaskTable.getATutorialTask(mission.missionId)) - else if (mission.currentAuditTaskId.isDefined) - AuditTaskTable.selectTaskFromTaskId(mission.currentAuditTaskId.get) - else - AuditTaskTable.selectANewTaskInARegion(regionId, user.userId, mission.missionId) - val nextTempLabelId: Int = LabelTable.nextTempLabelId(user.userId) + // If the mission has the wrong audit_task_id, update it. + if (task.isDefined && task.get.auditTaskId != mission.currentAuditTaskId) { + MissionTable.updateAuditProgressOnly(user.userId, mission.missionId, mission.distanceProgress.getOrElse(0F), task.get.auditTaskId) + mission = MissionTable.getMission(mission.missionId).get + } - // Check if they have already completed an audit mission. We send them to /validate after their first audit - // mission, but only after every third audit mission after that. - val completedMission: Boolean = MissionTable.countCompletedMissions(user.userId, missionType = "audit") > 0 + // Check if they have already completed an audit mission. We send them to /validate after their first audit + // mission, but only after every third audit mission after that. + val completedMissions: Boolean = MissionTable.countCompletedMissions(user.userId, missionType = "audit") > 0 - val cityStr: String = Play.configuration.getString("city-id").get - val tutorialStreetId: Int = Play.configuration.getInt("city-params.tutorial-street-edge-id." + cityStr).get - val cityShortName: String = Play.configuration.getString("city-params.city-short-name." + cityStr).get - if (missionSetProgress.missionType != "audit") { - Future.successful(Redirect("/validate")) - } else { - Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", task, mission, region.get, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId))) - } + val cityStr: String = Play.configuration.getString("city-id").get + val tutorialStreetId: Int = Play.configuration.getInt("city-params.tutorial-street-edge-id." + cityStr).get + val cityShortName: String = Play.configuration.getString("city-params.city-short-name." + cityStr).get + if (missionSetProgress.missionType != "audit") { + Future.successful(Redirect("/validate")) + } else { + Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", task, mission, region.get, userRoute, missionSetProgress.numComplete, completedMissions, nextTempLabelId, Some(user), cityShortName, tutorialStreetId))) } // For anonymous users. case None => // UTF-8 codes needed to pass a URL that contains parameters: ? is %3F, & is %26 - val redirectString: String = (nextRegion, retakeTutorial) match { - case (Some(nextR), Some(retakeT)) => s"/anonSignUp?url=/audit%3FnextRegion=$nextR%26retakeTutorial=$retakeT" - case (Some(nextR), None ) => s"/anonSignUp?url=/audit%3FnextRegion=$nextR" - case (None, Some(retakeT)) => s"/anonSignUp?url=/audit%3FretakeTutorial=$retakeT" - case _ => s"/anonSignUp?url=/audit" - } - Future.successful(Redirect(redirectString)) + val queryParams: String = routeId.map(rId => s"%3FrouteId=$rId").getOrElse("") + Future.successful(Redirect("/anonSignUp?url=/audit" + queryParams)) } } @@ -188,7 +195,7 @@ class AuditController @Inject() (implicit val env: Environment[User, SessionAuth if (missionSetProgress.missionType != "audit") { Future.successful(Redirect("/validate")) } else { - Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", task, mission, region, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId))) + Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", task, mission, region, None, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId))) } case None => Logger.error(s"Tried to audit region $regionId, but there is no neighborhood with that id.") @@ -258,15 +265,15 @@ class AuditController @Inject() (implicit val env: Environment[User, SessionAuth // If user is an admin and a panoId or lat/lng are supplied, send to that location, o/w send to street. if (isAdmin(request.identity) && (startAtPano || startAtLatLng)) { panoId match { - case Some(panoId) => Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId, None, None, Some(panoId)))) + case Some(panoId) => Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, None, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId, None, None, Some(panoId)))) case None => (lat, lng) match { - case (Some(lat), Some(lng)) => Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId, Some(lat), Some(lng)))) - case (_, _) => Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, missionSetProgress.numComplete, completedMission, nextTempLabelId, None, cityShortName, tutorialStreetId))) + case (Some(lat), Some(lng)) => Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, None, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId, Some(lat), Some(lng)))) + case (_, _) => Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, None, missionSetProgress.numComplete, completedMission, nextTempLabelId, None, cityShortName, tutorialStreetId))) } } } else { - Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId))) + Future.successful(Ok(views.html.audit("Project Sidewalk - Audit", Some(task), mission, region, None, missionSetProgress.numComplete, completedMission, nextTempLabelId, Some(user), cityShortName, tutorialStreetId))) } } } @@ -306,33 +313,4 @@ class AuditController @Inject() (implicit val env: Environment[User, SessionAuth } ) } - - /** - * This method handles a POST request in which user reports a missing Street View image. - */ - def postNoStreetView = UserAwareAction.async(BodyParsers.parse.json) { implicit request => - var submission = request.body.validate[NoStreetView] - - submission.fold( - errors => { - Future.successful(BadRequest(Json.obj("status" -> "Error", "message" -> JsError.toFlatJson(errors)))) - }, - submission => { - val userId: String = request.identity match { - case Some(user) => user.userId.toString - case None => - Logger.warn("User without a user_id reported no SV, but every user should have a user_id.") - val user: Option[DBUser] = UserTable.find("anonymous") - user.get.userId.toString - } - val timestamp: Timestamp = new Timestamp(Instant.now.toEpochMilli) - val ipAddress: String = request.remoteAddress - - val issue = StreetEdgeIssue(0, submission.streetEdgeId, "GSVNotAvailable", userId, ipAddress, timestamp) - StreetEdgeIssueTable.save(issue) - - Future.successful(Ok) - } - ) - } } diff --git a/app/controllers/RegionController.scala b/app/controllers/RegionController.scala index a50437184b..e3ac76c630 100644 --- a/app/controllers/RegionController.scala +++ b/app/controllers/RegionController.scala @@ -22,13 +22,6 @@ import models.region.RegionTable.MultiPolygonUtils class RegionController @Inject() (implicit val env: Environment[User, SessionAuthenticator]) extends Silhouette[User, SessionAuthenticator] with ProvidesHeader { - /** - * This returns the list of difficult neighborhood ids. - */ - def getDifficultNeighborhoods = Action.async { implicit request => - Future.successful(Ok(Json.obj("regionIds" -> RegionTable.difficultRegionIds))) - } - /** * Get list of all neighborhoods with a boolean indicating if the given user has fully audited that neighborhood. */ diff --git a/app/controllers/RouteBuilderController.scala b/app/controllers/RouteBuilderController.scala new file mode 100644 index 0000000000..971fd46aa1 --- /dev/null +++ b/app/controllers/RouteBuilderController.scala @@ -0,0 +1,50 @@ +package controllers + +import javax.inject.Inject +import com.mohiva.play.silhouette.api.{Environment, Silhouette} +import com.mohiva.play.silhouette.impl.authenticators.SessionAuthenticator +import play.api.libs.json._ +import controllers.headers.ProvidesHeader +import formats.json.RouteBuilderFormats.NewRoute +import models.daos.slick.DBTableDefinitions.{DBUser, UserTable} +import models.route.{Route, RouteStreet, RouteStreetTable, RouteTable} +import models.user.{User, WebpageActivity, WebpageActivityTable} +import scala.concurrent.Future +import play.api.mvc.BodyParsers +import java.sql.Timestamp +import java.time.Instant + +/** + * Holds the HTTP requests associated with managing neighborhoods. + * + * @param env The Silhouette environment. + */ +class RouteBuilderController @Inject() (implicit val env: Environment[User, SessionAuthenticator]) + extends Silhouette[User, SessionAuthenticator] with ProvidesHeader { + + val anonymousUser: DBUser = UserTable.find("anonymous").get + + def saveRoute = UserAwareAction.async(BodyParsers.parse.json) { implicit request => + val submission = request.body.validate[NewRoute] + submission.fold( + errors => { + Future.successful(BadRequest(Json.obj("status" -> "Error", "message" -> JsError.toFlatJson(errors)))) + }, + submission => { + val timestamp: Timestamp = new Timestamp(Instant.now.toEpochMilli) + val ipAddress: String = request.remoteAddress + val userIdStr: String = request.identity.map(_.userId.toString).getOrElse(anonymousUser.userId) + WebpageActivityTable.save(WebpageActivity(0, userIdStr, ipAddress, "SaveRoute", timestamp)) + + // Save new route in the database. + val newRouteId: Int = RouteTable.save(Route(0, userIdStr, submission.regionId, "temp", public = false, deleted = false)) + val newRouteStreets: Seq[RouteStreet] = submission.streetIds.zipWithIndex.map { case (streetId, index) => + RouteStreet(0, newRouteId, streetId, firstStreet = index == 0) + } + RouteStreetTable.saveMultiple(newRouteStreets) + + Future.successful(Ok(Json.obj("route_id" -> newRouteId))) + } + ) + } +} diff --git a/app/controllers/SignUpController.scala b/app/controllers/SignUpController.scala index d6f0a5f00c..dcc93893bd 100644 --- a/app/controllers/SignUpController.scala +++ b/app/controllers/SignUpController.scala @@ -97,7 +97,7 @@ class SignUpController @Inject() ( } yield { // Set the user role, assign the neighborhood to audit, and add to the user_stat table. UserRoleTable.setRole(user.userId, "Registered", Some(serviceHoursUser)) - UserCurrentRegionTable.assignEasyRegion(user.userId) + UserCurrentRegionTable.assignRegion(user.userId) UserStatTable.addUserStatIfNew(user.userId) // Log the sign up/in. @@ -292,7 +292,7 @@ class SignUpController @Inject() ( } yield { // Set the user role, assign the neighborhood to audit, and add to the user_stat table. UserRoleTable.setRole(user.userId, "Turker", Some(false)) - UserCurrentRegionTable.assignEasyRegion(user.userId) + UserCurrentRegionTable.assignRegion(user.userId) UserStatTable.addUserStatIfNew(user.userId) // Log the sign up/in. @@ -323,7 +323,7 @@ class SignUpController @Inject() ( val updatedAuthenticator = authenticator.copy(expirationDate=expirationDate, idleTimeout = Some(2592000)) if (!UserCurrentRegionTable.isAssigned(user.userId)) { - UserCurrentRegionTable.assignEasyRegion(user.userId) + UserCurrentRegionTable.assignRegion(user.userId) } // Log the sign in. diff --git a/app/controllers/TaskController.scala b/app/controllers/TaskController.scala index a2a18e934b..e21b6bc94d 100644 --- a/app/controllers/TaskController.scala +++ b/app/controllers/TaskController.scala @@ -18,8 +18,9 @@ import models.gsv.{GSVData, GSVDataTable, GSVLink, GSVLinkTable} import models.label._ import models.mission.{Mission, MissionTable} import models.region._ +import models.route.{AuditTaskUserRouteTable, UserRouteTable} import models.street.StreetEdgePriorityTable.streetPrioritiesFromIds -import models.street.{StreetEdgePriority, StreetEdgePriorityTable} +import models.street.{StreetEdgeIssue, StreetEdgeIssueTable, StreetEdgePriority, StreetEdgePriorityTable} import models.user.{User, UserCurrentRegionTable} import models.utils.CommonUtils.ordered import play.api.Play.current @@ -50,6 +51,35 @@ class TaskController @Inject() (implicit val env: Environment[User, SessionAuthe } } + /** + * This method handles a POST request in which user reports a missing Street View image. + */ + def postNoStreetView = UserAwareAction.async(BodyParsers.parse.json) { implicit request => + var submission = request.body.validate[Int] + + submission.fold( + errors => { + Future.successful(BadRequest(Json.obj("status" -> "Error", "message" -> JsError.toFlatJson(errors)))) + }, + streetEdgeId => { + val userId: String = request.identity match { + case Some(user) => user.userId.toString + case None => + Logger.warn("User without a user_id reported no SV, but every user should have a user_id.") + val user: Option[DBUser] = UserTable.find("anonymous") + user.get.userId.toString + } + val timestamp: Timestamp = new Timestamp(Instant.now.toEpochMilli) + val ipAddress: String = request.remoteAddress + + val issue: StreetEdgeIssue = StreetEdgeIssue(0, streetEdgeId, "GSVNotAvailable", userId, ipAddress, timestamp) + StreetEdgeIssueTable.save(issue) + + Future.successful(Ok) + } + ) + } + /** * Get the audit tasks in the given region for the signed in user. */ @@ -63,6 +93,11 @@ class TaskController @Inject() (implicit val env: Environment[User, SessionAuthe } } + def getTasksInARoute(userRouteId: Int) = Action.async { implicit request => + val tasks: List[JsObject] = UserRouteTable.selectTasksInRoute(userRouteId).map(_.toJSON) + Future.successful(Ok(JsArray(tasks))) + } + /** * Save completion end point of a partially complete task */ @@ -218,6 +253,12 @@ class TaskController @Inject() (implicit val env: Environment[User, SessionAuthe val auditTaskId: Int = updateAuditTaskTable(userOption, data.auditTask, missionId, data.amtAssignmentId) updateAuditTaskCompleteness(auditTaskId, data.auditTask, data.incomplete) + // Add to the audit_task_user_route and user_route tables if we are on a route and not in the tutorial. + if (data.userRouteId.isDefined && MissionTable.getMissionType(missionId) == Some("audit")) { + AuditTaskUserRouteTable.insertIfNew(data.userRouteId.get, auditTaskId) + UserRouteTable.updateCompleteness(data.userRouteId.get) + } + // Update MissionStart. if (data.auditTask.currentMissionStart.isDefined) updateMissionStart(auditTaskId, data.auditTask.currentMissionStart.get) diff --git a/app/controllers/UserProfileController.scala b/app/controllers/UserProfileController.scala index aab1e02003..ebd746e651 100644 --- a/app/controllers/UserProfileController.scala +++ b/app/controllers/UserProfileController.scala @@ -87,6 +87,7 @@ class UserProfileController @Inject() (implicit val env: Environment[User, Sessi val properties = Json.obj( "street_edge_id" -> edge.streetEdgeId, "way_type" -> edge.wayType, + "region_id" -> edge.regionId, "audited" -> edge.audited ) Json.obj("type" -> "Feature", "geometry" -> linestring, "properties" -> properties) diff --git a/app/formats/json/IssueFormats.scala b/app/formats/json/IssueFormats.scala deleted file mode 100644 index 782a999d6b..0000000000 --- a/app/formats/json/IssueFormats.scala +++ /dev/null @@ -1,13 +0,0 @@ -package formats.json - -import play.api.libs.json.{JsPath, Reads} -import play.api.libs.functional.syntax._ - -object IssueFormats { - case class NoStreetView(streetEdgeId: Int, space: String) - - implicit val noStreetViewReads: Reads[NoStreetView] = ( - (JsPath \ "street_edge_id").read[Int] and - (JsPath \ "issue").read[String] - )(NoStreetView.apply _) -} \ No newline at end of file diff --git a/app/formats/json/RouteBuilderFormats.scala b/app/formats/json/RouteBuilderFormats.scala new file mode 100644 index 0000000000..bd1a7870c5 --- /dev/null +++ b/app/formats/json/RouteBuilderFormats.scala @@ -0,0 +1,13 @@ +package formats.json + +import play.api.libs.json.{JsPath, Reads} +import play.api.libs.functional.syntax._ + +object RouteBuilderFormats { + case class NewRoute(regionId: Int, streetIds: Seq[Int]) + + implicit val newRouteReads: Reads[NewRoute] = ( + (JsPath \ "region_id").read[Int] and + (JsPath \ "street_ids").read[Seq[Int]] + )(NewRoute.apply _) +} diff --git a/app/formats/json/TaskSubmissionFormats.scala b/app/formats/json/TaskSubmissionFormats.scala index 2d383e912f..ff652d302b 100644 --- a/app/formats/json/TaskSubmissionFormats.scala +++ b/app/formats/json/TaskSubmissionFormats.scala @@ -1,6 +1,6 @@ package formats.json -import play.api.libs.json.{JsPath, Reads, Json} +import play.api.libs.json.{JsPath, Reads} import com.vividsolutions.jts.geom._ import scala.collection.immutable.Seq @@ -16,7 +16,7 @@ object TaskSubmissionFormats { case class GSVLinkSubmission(targetGsvPanoramaId: String, yawDeg: Double, description: String) case class GSVPanoramaSubmission(gsvPanoramaId: String, imageDate: String, imageWidth: Option[Int], imageHeight: Option[Int], tileWidth: Option[Int], tileHeight: Option[Int], links: Seq[GSVLinkSubmission], copyright: String) case class AuditMissionProgress(missionId: Int, distanceProgress: Option[Float], completed: Boolean, auditTaskId: Option[Int], skipped: Boolean) - case class AuditTaskSubmission(missionProgress: AuditMissionProgress, auditTask: TaskSubmission, labels: Seq[LabelSubmission], interactions: Seq[InteractionSubmission], environment: EnvironmentSubmission, incomplete: Option[IncompleteTaskSubmission], gsvPanoramas: Seq[GSVPanoramaSubmission], amtAssignmentId: Option[Int]) + case class AuditTaskSubmission(missionProgress: AuditMissionProgress, auditTask: TaskSubmission, labels: Seq[LabelSubmission], interactions: Seq[InteractionSubmission], environment: EnvironmentSubmission, incomplete: Option[IncompleteTaskSubmission], gsvPanoramas: Seq[GSVPanoramaSubmission], amtAssignmentId: Option[Int], userRouteId: Option[Int]) case class AMTAssignmentCompletionSubmission(assignmentId: Int, completed: Option[Boolean]) implicit val pointReads: Reads[Point] = ( @@ -126,7 +126,7 @@ object TaskSubmissionFormats { (JsPath \ "mission_id").read[Int] and (JsPath \ "distance_progress").readNullable[Float] and (JsPath \ "completed").read[Boolean] and - (JsPath \ "audit_task_id").read[Option[Int]] and + (JsPath \ "audit_task_id").readNullable[Int] and (JsPath \ "skipped").read[Boolean] )(AuditMissionProgress.apply _) @@ -138,7 +138,8 @@ object TaskSubmissionFormats { (JsPath \ "environment").read[EnvironmentSubmission] and (JsPath \ "incomplete").readNullable[IncompleteTaskSubmission] and (JsPath \ "gsv_panoramas").read[Seq[GSVPanoramaSubmission]] and - (JsPath \ "amt_assignment_id").readNullable[Int] + (JsPath \ "amt_assignment_id").readNullable[Int] and + (JsPath \ "user_route_id").readNullable[Int] )(AuditTaskSubmission.apply _) implicit val amtAssignmentCompletionReads: Reads[AMTAssignmentCompletionSubmission] = ( diff --git a/app/models/audit/AuditTaskTable.scala b/app/models/audit/AuditTaskTable.scala index a083dec232..47838adb69 100644 --- a/app/models/audit/AuditTaskTable.scala +++ b/app/models/audit/AuditTaskTable.scala @@ -28,6 +28,7 @@ case class NewTask(edgeId: Int, geom: LineString, completedByAnyUser: Boolean, // Notes if any user has audited this street. priority: Double, completed: Boolean, // Notes if the user audited this street before (null if no corresponding user). + auditTaskId: Option[Int], // If it's not actually a "new" task, include the audit_task_id. currentMissionId: Option[Int], currentMissionStart: Option[Point] // If a mission was started mid-task, the loc where it started. ) { @@ -51,6 +52,7 @@ case class NewTask(edgeId: Int, geom: LineString, "completed_by_any_user" -> completedByAnyUser, "priority" -> priority, "completed" -> completed, + "audit_task_id" -> auditTaskId, "current_mission_id" -> currentMissionId, "current_mission_start" -> currentMissionStart.map(p => geojson.LatLng(p.getY, p.getX)) ) @@ -79,7 +81,7 @@ case class AuditedStreetWithTimestamp(streetEdgeId: Int, auditTaskId: Int, } } -case class StreetEdgeWithAuditStatus(streetEdgeId: Int, geom: LineString, wayType: String, audited: Boolean) +case class StreetEdgeWithAuditStatus(streetEdgeId: Int, geom: LineString, regionId: Int, wayType: String, audited: Boolean) class AuditTaskTable(tag: slick.lifted.Tag) extends Table[AuditTask](tag, Some("sidewalk"), "audit_task") { def auditTaskId = column[Int]("audit_task_id", O.PrimaryKey, O.AutoInc) @@ -121,7 +123,7 @@ object AuditTaskTable { implicit val newTaskConverter = GetResult[NewTask](r => { NewTask(r.nextInt, r.nextGeometry[LineString], r.nextFloat, r.nextFloat, r.nextFloat, r.nextFloat, r.nextFloat, r.nextFloat, r.nextBoolean, r.nextTimestamp, r.nextBoolean, r.nextDouble, r.nextBooleanOption.getOrElse(false), - r.nextIntOption, r.nextGeometryOption[Point]) + r.nextIntOption, r.nextIntOption, r.nextGeometryOption[Point]) }) val db = play.api.db.slick.DB @@ -131,6 +133,10 @@ object AuditTaskTable { val streetEdgePriorities = TableQuery[StreetEdgePriorityTable] val users = TableQuery[UserTable] + val activeTasks = auditTasks + .leftJoin(AuditTaskIncompleteTable.incompletes).on(_.auditTaskId === _.auditTaskId) + .filter(x => !x._1.completed && x._2.auditTaskIncompleteId.?.isEmpty) + .map(_._1) val completedTasks = auditTasks.filter(_.completed) val streetEdgesWithoutDeleted = streetEdges.filterNot(_.deleted) val nonDeletedStreetEdgeRegions = StreetEdgeRegionTable.nonDeletedStreetEdgeRegions @@ -319,8 +325,9 @@ object AuditTaskTable { // Left join list of streets with list of audited streets to record whether each street has been audited. val streetsWithAuditedStatus = streetEdgesWithoutDeleted - .leftJoin(_distinctCompleted).on(_.streetEdgeId === _) - .map(s => (s._1.streetEdgeId, s._1.geom, s._1.wayType, !s._2.?.isEmpty)) + .innerJoin(StreetEdgeRegionTable.streetEdgeRegionTable).on(_.streetEdgeId === _.streetEdgeId) + .leftJoin(_distinctCompleted).on(_._1.streetEdgeId === _) + .map(s => (s._1._1.streetEdgeId, s._1._1.geom, s._1._2.regionId, s._1._1.wayType, !s._2.?.isEmpty)) streetsWithAuditedStatus.list.map(StreetEdgeWithAuditStatus.tupled) } @@ -381,7 +388,7 @@ object AuditTaskTable { se <- streetEdgesWithoutDeleted if se.streetEdgeId === streetEdgeId scau <- streetCompletedByAnyUser if se.streetEdgeId === scau._1 sep <- streetEdgePriorities if scau._1 === sep.streetEdgeId - } yield (se.streetEdgeId, se.geom, se.x2, se.y2, se.x1, se.y1, se.x2, se.y2, false, timestamp, scau._2, sep.priority, false, Some(missionId).asColumnOf[Option[Int]], None: Option[Point]) + } yield (se.streetEdgeId, se.geom, se.x2, se.y2, se.x1, se.y1, se.x2, se.y2, false, timestamp, scau._2, sep.priority, false, None: Option[Int], Some(missionId).asColumnOf[Option[Int]], None: Option[Point]) NewTask.tupled(edges.first) } @@ -393,7 +400,7 @@ object AuditTaskTable { val timestamp: Timestamp = new Timestamp(Instant.now.toEpochMilli) val tutorialTask = streetEdges .filter(_.streetEdgeId === LabelTable.tutorialStreetId) - .map(e => (e.streetEdgeId, e.geom, e.x2, e.y2, e.x1, e.y1, e.x2, e.y2, false, timestamp, false, 1.0, false, missionId.asColumnOf[Option[Int]], None: Option[Point])) + .map(e => (e.streetEdgeId, e.geom, e.x2, e.y2, e.x1, e.y1, e.x2, e.y2, false, timestamp, false, 1.0, false, None: Option[Int], missionId.asColumnOf[Option[Int]], None: Option[Point])) NewTask.tupled(tutorialTask.first) } @@ -411,7 +418,7 @@ object AuditTaskTable { sp <- streetEdgePriorities se <- edgesInRegion if sp.streetEdgeId === se.streetEdgeId sc <- streetCompletedByAnyUser if se.streetEdgeId === sc._1 - } yield (se.streetEdgeId, se.geom, se.x2, se.y2, se.x1, se.y1, se.x2, se.y2, false, timestamp, sc._2, sp.priority, false, Some(missionId).asColumnOf[Option[Int]], None: Option[Point]) + } yield (se.streetEdgeId, se.geom, se.x2, se.y2, se.x1, se.y1, se.x2, se.y2, false, timestamp, sc._2, sp.priority, false, None: Option[Int], Some(missionId).asColumnOf[Option[Int]], None: Option[Point]) // Get the priority of the highest priority task. val highestPriority: Option[Double] = possibleTasks.map(_._12).max.run @@ -430,11 +437,11 @@ object AuditTaskTable { */ def selectTaskFromTaskId(taskId: Int): Option[NewTask] = db.withSession { implicit session => val newTask = for { - at <- auditTasks if at.auditTaskId === taskId + at <- activeTasks if at.auditTaskId === taskId se <- streetEdges if at.streetEdgeId === se.streetEdgeId sp <- streetEdgePriorities if se.streetEdgeId === sp.streetEdgeId sc <- streetCompletedByAnyUser if sp.streetEdgeId === sc._1 - } yield (se.streetEdgeId, se.geom, at.currentLng, at.currentLat, se.x1, se.y1, se.x2, se.y2, at.startPointReversed, at.taskStart, sc._2, sp.priority, at.completed, at.currentMissionId, at.currentMissionStart) + } yield (se.streetEdgeId, se.geom, at.currentLng, at.currentLat, se.x1, se.y1, se.x2, se.y2, at.startPointReversed, at.taskStart, sc._2, sp.priority, at.completed, at.auditTaskId.?, at.currentMissionId, at.currentMissionStart) newTask.firstOption.map(NewTask.tupled) } @@ -447,20 +454,20 @@ object AuditTaskTable { val edgesInRegion = nonDeletedStreetEdgeRegions.filter(_.regionId === regionId) - // Get street_edge_id, task_start, current_mission_id, and current_mission_start for streets the user has audited. - // If there are multiple audit_tasks for the same street, choose most recent one (one w/ the highest audit_task_id). + // Get street_edge_id, task_start, audit_task_id, current_mission_id, and current_mission_start for streets the user + // has audited. If there are multiple for the same street, choose most recent (one w/ the highest audit_task_id). val userCompletedStreets = completedTasks .filter(_.userId === user.toString) .groupBy(_.streetEdgeId).map(_._2.map(_.auditTaskId).max) .innerJoin(auditTasks).on(_ === _.auditTaskId) - .map(t => (t._2.streetEdgeId, t._2.taskStart, t._2.currentMissionId, t._2.currentMissionStart)) + .map(t => (t._2.streetEdgeId, t._2.taskStart, t._2.auditTaskId, t._2.currentMissionId, t._2.currentMissionStart)) val tasks = for { (ser, ucs) <- edgesInRegion.leftJoin(userCompletedStreets).on(_.streetEdgeId === _._1) se <- streetEdges if ser.streetEdgeId === se.streetEdgeId sep <- streetEdgePriorities if se.streetEdgeId === sep.streetEdgeId scau <- streetCompletedByAnyUser if sep.streetEdgeId === scau._1 - } yield (se.streetEdgeId, se.geom, se.x2, se.y2, se.x1, se.y1, se.x2, se.y2, false, ucs._2.?.getOrElse(timestamp), scau._2, sep.priority, ucs._1.?.isDefined, ucs._3, ucs._4) + } yield (se.streetEdgeId, se.geom, se.x2, se.y2, se.x1, se.y1, se.x2, se.y2, false, ucs._2.?.getOrElse(timestamp), scau._2, sep.priority, ucs._1.?.isDefined, ucs._3.?, ucs._4, ucs._5) tasks.list.map(NewTask.tupled(_)) } diff --git a/app/models/region/RegionTable.scala b/app/models/region/RegionTable.scala index 26d86ec4f5..8918bd58e9 100644 --- a/app/models/region/RegionTable.scala +++ b/app/models/region/RegionTable.scala @@ -63,9 +63,6 @@ object RegionTable { val regions = TableQuery[RegionTable] val userCurrentRegions = TableQuery[UserCurrentRegionTable] - // These regions are buggy, so we steer new users away from them. - // TODO make this city-agnostic. List(251, 281, 317, 366) for DC. - val difficultRegionIds: List[Int] = List() val regionsWithoutDeleted = regions.filter(_.deleted === false) /** @@ -82,48 +79,20 @@ object RegionTable { regions.filter(_.regionId === regionId).map(_.name).firstOption } - /** - * Picks one of the regions with highest average priority. - */ - def selectAHighPriorityRegion: Option[Region] = db.withSession { implicit session => - val possibleRegionIds: List[Int] = regionsWithoutDeleted.map(_.regionId).list - - selectAHighPriorityRegionGeneric(possibleRegionIds) match { - case Some(region) => Some(region) - case _ => None // Should never happen. - } - } - /** * Picks one of the regions with highest average priority out of those that the user has not completed. */ def selectAHighPriorityRegion(userId: UUID): Option[Region] = db.withSession { implicit session => - val possibleRegionIds: List[Int] = AuditTaskTable.selectIncompleteRegions(userId).toList - - selectAHighPriorityRegionGeneric(possibleRegionIds) match { - case Some(region) => Some(region) - case _ => selectAHighPriorityRegion // Should only happen if user has completed all regions. - } - } + val regionsNotFinishedByUser: List[Int] = AuditTaskTable.selectIncompleteRegions(userId).toList - /** - * Picks one of the easy regions with highest average priority out of those that the user has not completed. - */ - def selectAHighPriorityEasyRegion(userId: UUID): Option[Region] = db.withSession { implicit session => - val possibleRegionIds: List[Int] = - AuditTaskTable.selectIncompleteRegions(userId).filterNot(difficultRegionIds.contains(_)).toList - - selectAHighPriorityRegionGeneric(possibleRegionIds) match { - case Some(region) => Some(region) - case _ => selectAHighPriorityRegion(userId) // Should only happen if user has completed all easy regions. - } + if (regionsNotFinishedByUser.nonEmpty) selectAHighPriorityRegionGeneric(regionsNotFinishedByUser) + else selectAHighPriorityRegionGeneric(regionsWithoutDeleted.map(_.regionId).list) } /** * Out of the provided regions, picks one of the 5 with highest average priority across their street edges. */ def selectAHighPriorityRegionGeneric(possibleRegionIds: List[Int]): Option[Region] = db.withSession { implicit session => - val highestPriorityRegions: List[Int] = StreetEdgeRegionTable.streetEdgeRegionTable .filter(_.regionId inSet possibleRegionIds) diff --git a/app/models/route/AuditTaskUserRouteTable.scala b/app/models/route/AuditTaskUserRouteTable.scala new file mode 100644 index 0000000000..dc3caf89ef --- /dev/null +++ b/app/models/route/AuditTaskUserRouteTable.scala @@ -0,0 +1,57 @@ +package models.route + +import models.audit.{AuditTask, AuditTaskTable} +import models.utils.MyPostgresDriver.simple._ +import play.api.Play.current +import scala.slick.lifted.ForeignKeyQuery + +case class AuditTaskUserRoute(auditTaskUserRouteId: Int, userRouteId: Int, auditTaskId: Int, routeStreetId: Int) + +class AuditTaskUserRouteTable(tag: slick.lifted.Tag) extends Table[AuditTaskUserRoute](tag, Some("sidewalk"), "audit_task_user_route") { + def auditTaskUserRouteId: Column[Int] = column[Int]("audit_task_user_route_id", O.PrimaryKey, O.AutoInc) + def userRouteId: Column[Int] = column[Int]("user_route_id", O.NotNull) + def auditTaskId: Column[Int] = column[Int]("audit_task_id", O.NotNull) + def routeStreetId: Column[Int] = column[Int]("route_street_id", O.NotNull) + + def * = (auditTaskUserRouteId, userRouteId, auditTaskId, routeStreetId) <> ((AuditTaskUserRoute.apply _).tupled, AuditTaskUserRoute.unapply) + + def userRoute: ForeignKeyQuery[UserRouteTable, UserRoute] = foreignKey("audit_task_user_route_user_route_id_fkey", userRouteId, TableQuery[UserRouteTable])(_.userRouteId) + def auditTask: ForeignKeyQuery[AuditTaskTable, AuditTask] = foreignKey("audit_task_user_route_audit_task_id_fkey", auditTaskId, TableQuery[AuditTaskTable])(_.auditTaskId) + def routeStreet: ForeignKeyQuery[RouteStreetTable, RouteStreet] = foreignKey("audit_task_user_route_route_street_id_fkey", routeStreetId, TableQuery[RouteStreetTable])(_.routeStreetId) +} + +/** + * Data access object for the route table. + */ +object AuditTaskUserRouteTable { + val db = play.api.db.slick.DB + val auditTaskUserRoutes = TableQuery[AuditTaskUserRouteTable] + + /** + * Adds a new entry if one doesn't exist. Returns true of a new entry was created. + */ + def insertIfNew(userRouteId: Int, auditTaskId: Int): Boolean = db.withSession { implicit session => + val entryExists = auditTaskUserRoutes.filter(x => x.userRouteId === userRouteId && x.auditTaskId === auditTaskId).size.run > 0 + if (entryExists) { + false + } else { + val streetsInRoute = UserRouteTable.userRoutes + .innerJoin(RouteStreetTable.routeStreets).on(_.routeId === _.routeId) + .filter(_._1.userRouteId === userRouteId) + .map(_._2) + val routeStreetId: Int = AuditTaskTable.auditTasks + .filter(_.auditTaskId === auditTaskId) + .innerJoin(streetsInRoute).on(_.streetEdgeId === _.streetEdgeId) + .map(_._2.routeStreetId).first + save(AuditTaskUserRoute(0, userRouteId, auditTaskId, routeStreetId)) + true + } + } + + /** + * Saves a new route. + */ + def save(newAuditTaskUserRoute: AuditTaskUserRoute): Int = db.withSession { implicit session => + auditTaskUserRoutes.insertOrUpdate(newAuditTaskUserRoute) + } +} diff --git a/app/models/route/RouteStreetTable.scala b/app/models/route/RouteStreetTable.scala new file mode 100644 index 0000000000..d635df2966 --- /dev/null +++ b/app/models/route/RouteStreetTable.scala @@ -0,0 +1,42 @@ +package models.route + +import models.street.{StreetEdge, StreetEdgeTable} +import models.utils.MyPostgresDriver.simple._ +import play.api.Play.current +import scala.slick.lifted.ForeignKeyQuery + +case class RouteStreet(routeStreetId: Int, routeId: Int, streetEdgeId: Int, firstStreet: Boolean) + +class RouteStreetTable(tag: slick.lifted.Tag) extends Table[RouteStreet](tag, Some("sidewalk"), "route_street") { + def routeStreetId: Column[Int] = column[Int]("route_street_id", O.PrimaryKey, O.AutoInc) + def routeId: Column[Int] = column[Int]("route_id", O.NotNull) + def streetEdgeId: Column[Int] = column[Int]("street_edge_id", O.NotNull) + def firstStreet: Column[Boolean] = column[Boolean]("first_street", O.NotNull) + + def * = (routeStreetId, routeId, streetEdgeId, firstStreet) <> ((RouteStreet.apply _).tupled, RouteStreet.unapply) + + def route: ForeignKeyQuery[RouteTable, Route] = foreignKey("route_street_route_id_fkey", routeId, TableQuery[RouteTable])(_.routeId) + def streetEdge: ForeignKeyQuery[StreetEdgeTable, StreetEdge] = foreignKey("route_street_street_edge_id_fkey", streetEdgeId, TableQuery[StreetEdgeTable])(_.streetEdgeId) +} + +/** + * Data access object for the route table. + */ +object RouteStreetTable { + val db = play.api.db.slick.DB + val routeStreets = TableQuery[RouteStreetTable] + + /** + * Saves a new route_street. + */ + def save(newRouteStreet: RouteStreet): Int = db.withSession { implicit session => + (routeStreets returning routeStreets.map(_.routeStreetId)) += newRouteStreet + } + + /** + * Inserts a sequence of new route_streets, presumably representing a complete route. + */ + def saveMultiple(newRouteStreets: Seq[RouteStreet]): Seq[Int] = db.withTransaction { implicit session => + (routeStreets returning routeStreets.map(_.routeStreetId)) ++= newRouteStreets + } +} diff --git a/app/models/route/RouteTable.scala b/app/models/route/RouteTable.scala new file mode 100644 index 0000000000..67eedcebbd --- /dev/null +++ b/app/models/route/RouteTable.scala @@ -0,0 +1,42 @@ +package models.route + +import models.daos.slick.DBTableDefinitions.{DBUser, UserTable} +import models.region.{Region, RegionTable} +import models.utils.MyPostgresDriver.simple._ +import play.api.Play.current +import scala.slick.lifted.ForeignKeyQuery + +case class Route(routeId: Int, userId: String, regionId: Int, name: String, public: Boolean, deleted: Boolean) + +class RouteTable(tag: slick.lifted.Tag) extends Table[Route](tag, Some("sidewalk"), "route") { + def routeId: Column[Int] = column[Int]("route_id", O.PrimaryKey, O.AutoInc) + def userId: Column[String] = column[String]("user_id", O.NotNull) + def regionId: Column[Int] = column[Int]("region_id", O.NotNull) + def name: Column[String] = column[String]("name", O.NotNull) + def public: Column[Boolean] = column[Boolean]("public", O.NotNull) + def deleted: Column[Boolean] = column[Boolean]("deleted", O.NotNull) + + def * = (routeId, userId, regionId, name, public, deleted) <> ((Route.apply _).tupled, Route.unapply) + + def user: ForeignKeyQuery[UserTable, DBUser] = foreignKey("route_user_id_fkey", userId, TableQuery[UserTable])(_.userId) + def region: ForeignKeyQuery[RegionTable, Region] = foreignKey("route_region_id_fkey", regionId, TableQuery[RegionTable])(_.regionId) +} + +/** + * Data access object for the route table. + */ +object RouteTable { + val db = play.api.db.slick.DB + val routes = TableQuery[RouteTable] + + def getRoute(routeId: Int): Option[Route] = db.withSession { implicit session => + routes.filter(_.routeId === routeId).firstOption + } + + /** + * Saves a new route. + */ + def save(newRoute: Route): Int = db.withSession { implicit session => + (routes returning routes.map(_.routeId)) += newRoute + } +} diff --git a/app/models/route/UserRouteTable.scala b/app/models/route/UserRouteTable.scala new file mode 100644 index 0000000000..f778414c32 --- /dev/null +++ b/app/models/route/UserRouteTable.scala @@ -0,0 +1,144 @@ +package models.route + +import models.audit.{AuditTaskTable, NewTask} +import models.daos.slick.DBTableDefinitions.{DBUser, UserTable} +import models.street.{StreetEdgePriorityTable, StreetEdgeTable} +import models.utils.MyPostgresDriver.simple._ +import play.api.Play.current +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID +import scala.slick.lifted.ForeignKeyQuery + +case class UserRoute(userRouteId: Int, routeId: Int, userId: String, completed: Boolean, discarded: Boolean) + +class UserRouteTable(tag: slick.lifted.Tag) extends Table[UserRoute](tag, Some("sidewalk"), "user_route") { + def userRouteId: Column[Int] = column[Int]("user_route_id", O.PrimaryKey, O.AutoInc) + def routeId: Column[Int] = column[Int]("route_id", O.NotNull) + def userId: Column[String] = column[String]("user_id", O.NotNull) + def completed: Column[Boolean] = column[Boolean]("completed", O.NotNull) + def discarded: Column[Boolean] = column[Boolean]("discarded", O.NotNull) + + def * = (userRouteId, routeId, userId, completed, discarded) <> ((UserRoute.apply _).tupled, UserRoute.unapply) + + def route: ForeignKeyQuery[RouteTable, Route] = foreignKey("user_route_route_id_fkey", routeId, TableQuery[RouteTable])(_.routeId) + def user: ForeignKeyQuery[UserTable, DBUser] = foreignKey("user_route_user_id_fkey", userId, TableQuery[UserTable])(_.userId) +} + +/** + * Data access object for the route table. + */ +object UserRouteTable { + val db = play.api.db.slick.DB + val userRoutes = TableQuery[UserRouteTable] + val activeRoutes = userRoutes.filter(ur => !ur.completed && !ur.discarded) + + def setUpPossibleUserRoute(routeId: Option[Int], userId: UUID, resumeRoute: Boolean): Option[UserRoute] = db.withSession { implicit session => + (routeId, resumeRoute) match { + case (Some(rId), true) => + // Discard routes that don't match routeId, resume route with given routeId if it exists, o/w make a new one. + activeRoutes.filter(x => x.routeId =!= rId && x.userId === userId.toString).map(_.discarded).update(true) + + Some(activeRoutes + .filter(ur => ur.routeId === rId && ur.userId === userId.toString) + .firstOption.getOrElse(save(UserRoute(0, rId, userId.toString, completed = false, discarded = false)))) + case (Some(rId), false) => + // Discard old routes, save a new one with given routeId. + activeRoutes.filter(_.userId === userId.toString).map(_.discarded).update(true) + Some(save(UserRoute(0, rId, userId.toString, completed = false, discarded = false))) + case (None, true) => + // Get an in progress route (with any routeId) if it exists, otherwise return None. + activeRoutes.filter(_.userId === userId.toString).firstOption + case (None, false) => + // Discard old routes, return None. + activeRoutes.filter(_.userId === userId.toString).map(_.discarded).update(true) + None + } + } + + def selectTasksInRoute(userRouteId: Int): List[NewTask] = db.withSession { implicit session => + val timestamp: Timestamp = new Timestamp(Instant.now.toEpochMilli) + + val edgesInRoute = userRoutes + .filter(_.userRouteId === userRouteId) + .innerJoin(RouteStreetTable.routeStreets).on(_.routeId === _.routeId) + .innerJoin(StreetEdgeTable.streetEdgesWithoutDeleted).on(_._2.streetEdgeId === _.streetEdgeId) + .map(_._2) + + // Get street_edge_id, task_start, audit_task_id, current_mission_id, and current_mission_start for streets the user + // has audited. If there are multiple for the same street, choose most recent (one w/ the highest audit_task_id). + val userCompletedStreets = AuditTaskUserRouteTable.auditTaskUserRoutes + .filter(_.userRouteId === userRouteId) + .innerJoin(AuditTaskTable.completedTasks).on(_.auditTaskId === _.auditTaskId) + .groupBy(_._2.streetEdgeId).map(_._2.map(_._2.auditTaskId).max) + .innerJoin(AuditTaskTable.auditTasks).on(_ === _.auditTaskId) + .map(t => (t._2.streetEdgeId, t._2.taskStart, t._2.auditTaskId, t._2.currentMissionId, t._2.currentMissionStart)) + + val tasks = for { + (ser, ucs) <- edgesInRoute.leftJoin(userCompletedStreets).on(_.streetEdgeId === _._1) + se <- StreetEdgeTable.streetEdges if ser.streetEdgeId === se.streetEdgeId + sep <- StreetEdgePriorityTable.streetEdgePriorities if se.streetEdgeId === sep.streetEdgeId + scau <- AuditTaskTable.streetCompletedByAnyUser if sep.streetEdgeId === scau._1 + } yield (se.streetEdgeId, se.geom, se.x2, se.y2, se.x1, se.y1, se.x2, se.y2, false, ucs._2.?.getOrElse(timestamp), scau._2, sep.priority, ucs._1.?.isDefined, ucs._3.?, ucs._4, ucs._5) + + tasks.list.map(NewTask.tupled(_)) + } + + /** + * Get the active audit_task for the given UserRoute. If there is none, create a new task and return it. + * + * @param currRoute + * @param missionId + * @return + */ + def getRouteTask(currRoute: UserRoute, missionId: Int): Option[NewTask] = db.withSession { implicit session => + val currTaskId: Option[Int] = AuditTaskUserRouteTable.auditTaskUserRoutes + .innerJoin(AuditTaskTable.auditTasks).on(_.auditTaskId === _.auditTaskId) + .filter(x => x._1.userRouteId === currRoute.userRouteId && x._2.completed === false) + .map(_._1.auditTaskId).firstOption + val possibleTask: Option[NewTask] = currTaskId.flatMap(AuditTaskTable.selectTaskFromTaskId) + + if (possibleTask.isDefined) { + possibleTask + } else { + val userTasks = AuditTaskUserRouteTable.auditTaskUserRoutes.filter(_.userRouteId === currRoute.userRouteId) + val nextStreetId: Option[Int] = RouteStreetTable.routeStreets + .leftJoin(userTasks).on(_.routeStreetId === _.routeStreetId) + .filter(x => x._1.routeId === currRoute.routeId && x._2.auditTaskUserRouteId.?.isEmpty) + .sortBy(_._1.routeStreetId) + .map(_._1.streetEdgeId).firstOption + nextStreetId.map(AuditTaskTable.selectANewTask(_, missionId)) + } + } + + /** + * Check if the given user route has been finished based on the audit_task table. Mark as complete if so. + * + * @param userRouteId + * @return + */ + def updateCompleteness(userRouteId: Int): Boolean = db.withSession { implicit session => + // Get the completed audit_tasks that are a part of this user_route. + val userAudits = AuditTaskUserRouteTable.auditTaskUserRoutes + .innerJoin(AuditTaskTable.completedTasks).on(_.auditTaskId === _.auditTaskId) + .filter(_._1.userRouteId === userRouteId) + + // Check if all streets in the route have a completed audit using an outer join. If so, mark as complete in db. + val complete: Boolean = userRoutes + .innerJoin(RouteStreetTable.routeStreets).on(_.routeId === _.routeId) + .leftJoin(userAudits).on(_._2.routeStreetId === _._1.routeStreetId) + .filter(x => x._1._1.userRouteId === userRouteId && x._2._2.auditTaskId.?.isEmpty).size.run == 0 + if (complete) { + val q = for { ur <- userRoutes if ur.userRouteId === userRouteId } yield ur.completed + q.update(complete) + } + complete + } + + /** + * Saves a new route. + */ + def save(newUserRoute: UserRoute): UserRoute = db.withSession { implicit session => + (userRoutes returning userRoutes) += newUserRoute + } +} diff --git a/app/models/user/UserCurrentRegionTable.scala b/app/models/user/UserCurrentRegionTable.scala index c00c9d67df..845457523f 100644 --- a/app/models/user/UserCurrentRegionTable.scala +++ b/app/models/user/UserCurrentRegionTable.scala @@ -24,9 +24,7 @@ object UserCurrentRegionTable { val userCurrentRegions = TableQuery[UserCurrentRegionTable] val regions = TableQuery[RegionTable] - val neighborhoods = regions.filter(_.deleted === false) - - val experiencedUserMileageThreshold = 2.0 + val regionsWithoutDeleted = RegionTable.regionsWithoutDeleted def save(userId: UUID, regionId: Int): Int = db.withTransaction { implicit session => val userCurrentRegion = UserCurrentRegion(0, userId.toString, regionId) @@ -36,29 +34,10 @@ object UserCurrentRegionTable { } /** - * Checks if the given user is "experienced" (have audited at least 2 miles). - */ - def isUserExperienced(userId: UUID): Boolean = db.withSession { implicit session => - AuditTaskTable.getDistanceAudited(userId) * METERS_TO_MILES > experiencedUserMileageThreshold - } - - /** - * Select an easy region w/ high avg street priority where the user hasn't completed all missions; assign it to them. - */ - def assignEasyRegion(userId: UUID): Option[Region] = db.withSession { implicit session => - val newRegion: Option[Region] = RegionTable.selectAHighPriorityEasyRegion(userId) - newRegion.map(r => saveOrUpdate(userId, r.regionId)) // If region successfully selected, assign it to them. - newRegion - } - - /** - * Select a region with high avg street priority, where the user hasn't completed all missions; assign it to them. + * Select a region with high avg street priority, where the user hasn't explored every street; assign it to them. */ def assignRegion(userId: UUID): Option[Region] = db.withSession { implicit session => - // If user is inexperienced, restrict them to only easy regions when selecting a high priority region. - val newRegion: Option[Region] = - if(isUserExperienced(userId)) RegionTable.selectAHighPriorityRegion(userId) - else RegionTable.selectAHighPriorityEasyRegion(userId) + val newRegion: Option[Region] = RegionTable.selectAHighPriorityRegion(userId) newRegion.map(r => saveOrUpdate(userId, r.regionId)) // If region successfully selected, assign it to them. newRegion } @@ -67,24 +46,14 @@ object UserCurrentRegionTable { * Returns the region id that is currently assigned to the given user. */ def currentRegion(userId: UUID): Option[Int] = db.withSession { implicit session => - // Get rid of deleted regions. - val ucr = for { - (ucr, r) <- userCurrentRegions.innerJoin(neighborhoods).on(_.regionId === _.regionId) - } yield ucr - - ucr.filter(_.userId === userId.toString).list.map(_.regionId).headOption + userCurrentRegions.filter(_.userId === userId.toString).map(_.regionId).firstOption } /** - * Check if a user has been assigned to some region. + * Check if a user is assigned to some region. */ def isAssigned(userId: UUID): Boolean = db.withSession { implicit session => - val _userCurrentRegions = for { - (_regions, _userCurrentRegions) <- neighborhoods.innerJoin(userCurrentRegions).on(_.regionId === _.regionId) - if _userCurrentRegions.userId === userId.toString - } yield _userCurrentRegions - - _userCurrentRegions.list.nonEmpty + userCurrentRegions.filter(_.userId === userId.toString).size.run > 0 } /** diff --git a/app/views/admin/index.scala.html b/app/views/admin/index.scala.html index 0451b00cf5..36ce3c092e 100644 --- a/app/views/admin/index.scala.html +++ b/app/views/admin/index.scala.html @@ -1,12 +1,10 @@ @import models.user.User -@import models.region.RegionTable @import models.daos.slick._ @import models.audit._ @import models.label._ @import models.street.StreetEdgeTable @import models.audit.AuditTaskCommentTable @import models.utils.DataFormatter -@import play.api.libs.json.Json @(title: String, user: Option[User] = None)(implicit lang: Lang) @convertDistance(distance: Float) = @{ @@ -978,8 +976,7 @@

Label Search

lng: '@lang.code', debug: false }, function(err, t) { - var difficultRegionIds = @Json.toJson(RegionTable.difficultRegionIds); - window.admin = Admin(_, $, difficultRegionIds); + window.admin = Admin(_, $); $('#comments-table').dataTable(); $('#label-table').dataTable(); $('#user-table').dataTable(); diff --git a/app/views/audit.scala.html b/app/views/audit.scala.html index b093757b46..f525561a6f 100644 --- a/app/views/audit.scala.html +++ b/app/views/audit.scala.html @@ -1,13 +1,12 @@ @import models.user.User -@import models.region.RegionTable @import models.region.Region @import models.mission.{MissionTable, Mission} @import models.survey._ @import models.amt.AMTAssignmentTable -@import play.api.libs.json.Json +@import models.route.UserRoute @import views.html.bootstrap._ -@(title: String, task: Option[models.audit.NewTask] = None, mission: Mission, region: Region, missionSetProgress: Int, hasCompletedMission: Boolean, nextTempLabelId: Int, user: Option[User] = None, cityShortName: String, tutorialStreetId: Int, lat: Option[Double] = None, lng: Option[Double] = None, panoId: Option[String] = None)(implicit lang: Lang) +@(title: String, task: Option[models.audit.NewTask] = None, mission: Mission, region: Region, userRoute: Option[UserRoute], missionSetProgress: Int, hasCompletedMission: Boolean, nextTempLabelId: Int, user: Option[User] = None, cityShortName: String, tutorialStreetId: Int, lat: Option[Double] = None, lng: Option[Double] = None, panoId: Option[String] = None)(implicit lang: Lang) @main(title, Some("/audit")) { @navbar(user, Some("/audit")) @@ -144,32 +143,11 @@

@Html(Messages("audit.survey." + surveyQuestion.surv