From bbc21f2089b2295a5c6a3134a4edebb7881e8801 Mon Sep 17 00:00:00 2001 From: CollinBeczak Date: Sat, 31 Aug 2024 21:28:58 -0500 Subject: [PATCH] fix challenge and project leaderboards --- .../controller/LeaderboardController.scala | 112 ++++++ .../repository/LeaderboardRepository.scala | 57 +++ .../service/LeaderboardService.scala | 352 ++++++++++++++++++ conf/v2_route/leaderboard.api | 28 ++ 4 files changed, 549 insertions(+) diff --git a/app/org/maproulette/framework/controller/LeaderboardController.scala b/app/org/maproulette/framework/controller/LeaderboardController.scala index 6c7aded3..916f5f3d 100644 --- a/app/org/maproulette/framework/controller/LeaderboardController.scala +++ b/app/org/maproulette/framework/controller/LeaderboardController.scala @@ -52,6 +52,118 @@ class LeaderboardController @Inject() ( } } + /** + * Gets the top scoring users, based on task completion, over the given + * number of months (or using start and end dates). Included with each user is their top challenges + * (by amount of activity). + * + * @param monthDuration the number of months to consider for the leaderboard + * @param maxChallengesInList the maximum number of top challenges to include for each user + * @param limit the limit on the number of users returned + * @param offset the number of users to skip before starting to return results (for pagination) + * @return Top-ranked users with scores based on task completion activity + */ + def getMainLeaderboard( + monthDuration: Int, + maxChallengesInList: Int, + limit: Int, + offset: Int + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + Ok( + Json.toJson( + this.service.getMainLeaderboard(monthDuration, maxChallengesInList, limit, offset) + ) + ) + } + } + + /** + * Gets the top scoring users, based on task completion, over the given + * number of months (or using start and end dates). Included with each user is their top challenges + * (by amount of activity). + * + * @param id the ID of the challenge + * @param monthDuration the number of months to consider for the leaderboard + * @param maxChallengesInList the maximum number of top challenges to include for each user + * @param limit the limit on the number of users returned + * @param offset the number of users to skip before starting to return results (for pagination) + * @return Top-ranked users with scores based on task completion activity + */ + def getChallengeLeaderboard( + id: Int, + monthDuration: Int, + maxChallengesInList: Int, + limit: Int, + offset: Int + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + Ok( + Json.toJson( + this.service + .getChallengeLeaderboard(id, monthDuration, maxChallengesInList, limit, offset) + ) + ) + } + } + + /** + * Gets the top scoring users, based on task completion, over the given + * number of months (or using start and end dates). Included with each user is their top challenges + * (by amount of activity). + * + * @param id the ID of the project + * @param monthDuration the number of months to consider for the leaderboard + * @param maxChallengesInList the maximum number of top challenges to include for each user + * @param limit the limit on the number of users returned + * @param offset the number of users to skip before starting to return results (for pagination) + * @return Top-ranked users with scores based on task completion activity + */ + def getProjectLeaderboard( + id: Int, + monthDuration: Int, + maxChallengesInList: Int, + limit: Int, + offset: Int + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + Ok( + Json.toJson( + this.service.getProjectLeaderboard(id, monthDuration, maxChallengesInList, limit, offset) + ) + ) + } + } + + /** + * Gets the top scoring users, based on task completion, over the given + * number of months (or using start and end dates). Included with each user is their top challenges + * (by amount of activity). + * + * @param country the country to filter the leaderboard by + * @param monthDuration the number of months to consider for the leaderboard + * @param maxChallengesInList the maximum number of top challenges to include for each user + * @param limit the limit on the number of users returned + * @param offset the number of users to skip before starting to return results (for pagination) + * @return Top-ranked users with scores based on task completion activity + */ + def getCountryLeaderboard( + country: String, + monthDuration: Int, + maxChallengesInList: Int, + limit: Int, + offset: Int + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + Ok( + Json.toJson( + this.service + .getCountryLeaderboard(country, monthDuration, maxChallengesInList, limit, offset) + ) + ) + } + } + /** * Gets the leaderboard ranking for a user, based on task completion, over * the given number of months (or start and end dates). Included with the user is their top challenges diff --git a/app/org/maproulette/framework/repository/LeaderboardRepository.scala b/app/org/maproulette/framework/repository/LeaderboardRepository.scala index 37377afa..1b156a18 100644 --- a/app/org/maproulette/framework/repository/LeaderboardRepository.scala +++ b/app/org/maproulette/framework/repository/LeaderboardRepository.scala @@ -149,6 +149,63 @@ class LeaderboardRepository @Inject() (override val db: Database) extends Reposi } } + /** + * Queries the user_leaderboard table + * + * @param query + * @param getTopChallengesBlock - function to return the top challenges for a user id + * @return List of LeaderboardUsers + **/ + def queryMainLeaderboard( + query: Query, + getTopChallengesBlock: Long => List[LeaderboardChallenge] + ): List[LeaderboardUser] = { + withMRConnection { implicit c => + query + .build( + """ + SELECT *, + COALESCE(user_leaderboard.completed_tasks, 0) as completed_tasks, + COALESCE(user_leaderboard.avg_time_spent, 0) as avg_time_spent + FROM user_leaderboard + """ + ) + .as(this.userLeaderboardParser(getTopChallengesBlock).*) + } + } + + /** + * Queries the user_leaderboard table + * + * @param query + * @param getTopChallengesBlock - function to return the top challenges for a user id + * @return List of LeaderboardUsers + **/ + def queryChallengeLeaderboard( + query: Query, + getTopChallengesBlock: Long => List[LeaderboardChallenge] + ): List[LeaderboardUser] = { + withMRConnection { implicit c => + query + .build( + """ + SELECT * FROM user_top_challengess + """ + ) + .as(this.leaderboardChallengeParser.*) + } + } + + def queryLeaderboardChallenges(query: Query): List[LeaderboardChallenge] = { + withMRConnection { implicit c => + query + .build( + "SELECT challenge_id, challenge_name, activity FROM user_top_challenges" + ) + .as(this.leaderboardChallengeParser.*) + } + } + /** * Queries the user_leaderboard table with ranking sql * diff --git a/app/org/maproulette/framework/service/LeaderboardService.scala b/app/org/maproulette/framework/service/LeaderboardService.scala index 7b98e41d..017309e1 100644 --- a/app/org/maproulette/framework/service/LeaderboardService.scala +++ b/app/org/maproulette/framework/service/LeaderboardService.scala @@ -184,6 +184,177 @@ class LeaderboardService @Inject() ( ) } + def getMainLeaderboard( + monthDuration: Int = 1, + maxChallengesInList: Int = 3, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 1 + ): List[LeaderboardUser] = { + val result = this.repository.queryMainLeaderboard( + Query.simple( + List( + BaseParameter( + "month_duration", + monthDuration, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ), + BaseParameter( + "country_code", + None, + Operator.NULL, + useValueDirectly = true, + table = Some("") + ) + ), + paging = Paging(limit, offset), + order = Order(List(OrderField("user_ranking", Order.ASC, table = Some("")))) + ), + fetchedUserId => + this.getMainLeaderboardChallenges(fetchedUserId, monthDuration, maxChallengesInList, offset) + ) + + return result + } + + def getChallengeLeaderboard( + challengeId: Int, + monthDuration: Int = 1, + maxChallengesInList: Int = 3, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 1 + ): List[LeaderboardUser] = { + val result = this.repository.queryChallengeLeaderboard( + Query.simple( + List( + BaseParameter( + "challenge_id", + challengeId, + Operator.EQ, + useValueDirectly = true, + table = Some("user_top_challenges") + ), + BaseParameter( + "month_duration", + monthDuration, + Operator.EQ, + useValueDirectly = true, + table = Some("user_top_challenges") + ), + BaseParameter( + "country_code", + None, + Operator.NULL, + useValueDirectly = true, + table = Some("") + ) + ), + paging = Paging(limit, offset), + order = Order(List(OrderField("activity", Order.DESC, table = Some("user_top_challenges")))) + ), + fetchedUserId => + this.getChallengeLeaderboardChallenges( + fetchedUserId, + challengeId, + monthDuration, + maxChallengesInList, + offset + ) + ) + + return result + } + + def getProjectLeaderboard( + projectId: Int, + monthDuration: Int = 1, + maxChallengesInList: Int = 3, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 1 + ): List[LeaderboardUser] = { + val result = this.repository.queryUserLeaderboard( + Query.simple( + List( + BaseParameter( + "project_id", + projectId, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ), + BaseParameter( + "month_duration", + monthDuration, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ), + BaseParameter( + "country_code", + None, + Operator.NULL, + useValueDirectly = true, + table = Some("") + ) + ), + paging = Paging(limit, offset), + order = Order(List(OrderField("user_ranking", Order.ASC, table = Some("projects")))) + ), + fetchedUserId => + this.getProjectLeaderboardChallenges( + fetchedUserId, + projectId, + monthDuration, + maxChallengesInList, + offset + ) + ) + + return result + } + + def getCountryLeaderboard( + country: String, + monthDuration: Int = 1, + maxChallengesInList: Int = 3, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 1 + ): List[LeaderboardUser] = { + val result = this.repository.queryUserLeaderboard( + Query.simple( + List( + BaseParameter( + "country_code", + country, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ), + BaseParameter( + "month_duration", + monthDuration, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) + ), + paging = Paging(limit, offset), + order = Order(List(OrderField("user_ranking", Order.DESC, table = Some("countries")))) + ), + fetchedUserId => + this.getCountryLeaderboardChallenges( + fetchedUserId, + country, + monthDuration, + maxChallengesInList, + offset + ) + ) + + return result + } + /** * Gets leaderboard rank for a user based on task completion activity * over the given period. Scoring for each completed task is based on status @@ -266,6 +437,165 @@ class LeaderboardService @Inject() ( ) } + def getMainLeaderboardChallenges( + userId: Long, + monthDuration: Int = 1, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 0 + ): List[LeaderboardChallenge] = { + // The userId must exist and must not be a system user, otherwise return NotFound (http 404). + if (userId <= 0 || this.userService.retrieve(userId).isEmpty) { + throw new NotFoundException(s"No user found with id $userId") + } + + val result = this.repository.queryLeaderboardChallenges( + Query.simple( + BaseParameter( + "user_id", + userId, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) :: this.lessBasicFilters(monthDuration), + paging = Paging(limit, offset), + order = Order( + List( + OrderField("activity", Order.DESC, table = Some("")), + OrderField("challenge_id", Order.ASC, table = Some("")) + ) + ) + ) + ) + + return result + } + + def getChallengeLeaderboardChallenges( + userId: Long, + challengeId: Int, + monthDuration: Int = 1, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 0 + ): List[LeaderboardChallenge] = { + // Validate the userId and ensure the user exists + if (userId <= 0 || this.userService.retrieve(userId).isEmpty) { + throw new NotFoundException(s"No user found with id $userId") + } + + // Filter by userId and challengeId, and apply the same ordering and paging + val result = this.repository.queryLeaderboardChallenges( + Query.simple( + BaseParameter( + "user_id", + userId, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) :: + BaseParameter( + "challenge_id", + challengeId, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) :: this.lessBasicFilters(monthDuration), + paging = Paging(limit, offset), + order = Order( + List( + OrderField("activity", Order.DESC, table = Some("")), + OrderField("challenge_id", Order.ASC, table = Some("")) + ) + ) + ) + ) + + result + } + + def getProjectLeaderboardChallenges( + userId: Long, + projectId: Int, + monthDuration: Int = 1, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 0 + ): List[LeaderboardChallenge] = { + // Validate the userId and ensure the user exists + if (userId <= 0 || this.userService.retrieve(userId).isEmpty) { + throw new NotFoundException(s"No user found with id $userId") + } + + // Filter by userId and projectId, and apply the same ordering and paging + val result = this.repository.queryLeaderboardChallenges( + Query.simple( + BaseParameter( + "user_id", + userId, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) :: + BaseParameter( + "project_id", + projectId, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) :: this.lessBasicFilters(monthDuration), + paging = Paging(limit, offset), + order = Order( + List( + OrderField("activity", Order.DESC, table = Some("")), + OrderField("challenge_id", Order.ASC, table = Some("")) + ) + ) + ) + ) + + result + } + + def getCountryLeaderboardChallenges( + userId: Long, + country: String, + monthDuration: Int = 1, + limit: Int = Config.DEFAULT_LIST_SIZE, + offset: Int = 0 + ): List[LeaderboardChallenge] = { + // Validate the userId and ensure the user exists + if (userId <= 0 || this.userService.retrieve(userId).isEmpty) { + throw new NotFoundException(s"No user found with id $userId") + } + + // Filter by userId and country, and apply the same ordering and paging + val result = this.repository.queryLeaderboardChallenges( + Query.simple( + BaseParameter( + "user_id", + userId, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) :: + BaseParameter( + "country", + country, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ) :: this.lessBasicFilters(monthDuration), + paging = Paging(limit, offset), + order = Order( + List( + OrderField("activity", Order.DESC, table = Some("")), + OrderField("challenge_id", Order.ASC, table = Some("")) + ) + ) + ) + ) + + result + } + /** * Gets the top challenges by activity for the given user over the given period. * Challenges are in descending order by amount of activity, with ties broken @@ -475,6 +805,28 @@ class LeaderboardService @Inject() ( ) } + // Returns basic filters for monthDuration and countryCode + private def lessBasicFilters( + monthDuration: Int + ): List[Parameter[_]] = { + List( + BaseParameter( + "month_duration", + monthDuration, + Operator.EQ, + useValueDirectly = true, + table = Some("") + ), + BaseParameter( + "country_code", + None, + Operator.NULL, + useValueDirectly = true, + table = Some("") + ) + ) + } + // Returns basic filters for monthDuration and countryCode private def basicFilters( monthDuration: Option[Int], diff --git a/conf/v2_route/leaderboard.api b/conf/v2_route/leaderboard.api index 5f5cc284..2b4a50ef 100644 --- a/conf/v2_route/leaderboard.api +++ b/conf/v2_route/leaderboard.api @@ -68,6 +68,34 @@ GET /data/user/leaderboard @org.maproulette.framework.controller.LeaderboardController.getMapperLeaderboard(limit:Int ?= 20, offset:Int ?= 0) ### # tags: [ Leaderboard ] +# summary: Fetches main leaderboard +# description: Fetches the the top mappers of a time period +# responses: +# '200': +# description: List of leaderboard stats +# parameters: +# - name: monthDuration +# in: query +# description: The optional number of past months to search by (with 0 as current month and -1 as all time) +# required: false +# schema: +# type: integer +# - name: limit +# in: query +# description: The number of results +# required: false +# schema: +# type: integer +# - name: offset +# in: query +# description: The number of rows to skip before starting to return the results. Used for pagination. +# required: false +# schema: +# type: integer +### +GET /data/user/mainLeaderboard @org.maproulette.framework.controller.LeaderboardController.getMainLeaderboard(monthDuration:Int ?= 1, maxChallengesInList:Int ?= 3, limit:Int ?= 20, offset:Int ?= 0) +### +# tags: [ Leaderboard ] # summary: Fetches leaderboard stats with ranking for the user # description: Fetches user's current ranking and stats in the leaderboard along with a number of mappers above and below in the rankings. # responses: