Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md).
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
package org.maproulette.framework.controller

import javax.inject.Inject
import org.maproulette.data.ActionManager
import org.maproulette.exception.InvalidException
import org.maproulette.framework.model.ChallengeReport
import org.maproulette.framework.service.ChallengeReportService
import org.maproulette.session.SessionManager
import play.api.libs.json.{JsValue, Json}
import play.api.mvc._

/**
* Endpoints for reports filed against a challenge's design. Any authenticated
* user can file one; only superusers can list or triage them.
*/
class ChallengeReportController @Inject() (
override val sessionManager: SessionManager,
override val actionManager: ActionManager,
override val bodyParsers: PlayBodyParsers,
challengeReportService: ChallengeReportService,
components: ControllerComponents
) extends AbstractController(components)
with MapRouletteController {

/**
* Files a report against a challenge. The reporter is taken from the session
* rather than the request body, so a report cannot be attributed to someone
* else.
*
* @param challengeId The challenge being reported
* @return 201 Created with the new report
*/
def create(challengeId: Long): Action[JsValue] = Action.async(bodyParsers.json) {
implicit request =>
this.sessionManager.authenticatedRequest { implicit user =>
val comment = (request.body \ "comment")
.asOpt[String]
.getOrElse(
throw new InvalidException("Required 'comment' field in request body not found.")
)
val email = (request.body \ "email").asOpt[String]

Created(
Json.toJson(this.challengeReportService.create(user, challengeId, comment, email))
)
}
}

/**
* Reports whether the requesting user already has an open report against a
* challenge, so the report button can explain itself rather than letting the
* user write a duplicate and be rejected on submit. Returns only the
* caller's own report, so it needs no elevated permission.
*
* @param challengeId The challenge in question
* @return The user's open report, or 204 if they have none
*/
def retrieveOwnOpenReport(challengeId: Long): Action[AnyContent] = Action.async {
implicit request =>
this.sessionManager.authenticatedRequest { implicit user =>
this.challengeReportService.retrieveOwnOpenReport(user, challengeId) match {
case Some(report) => Ok(Json.toJson(report))
case None => NoContent
}
}
}

/**
* Lists reports, newest first. Superusers only.
*
* @param status Restrict to one triage status, by name ("open", "actioned", "dismissed")
* @param challengeId Restrict to a single challenge
* @param activeOnly Only reports on challenges that are neither deleted nor archived
* @param limit Page size
* @param page Zero-based page number
* @return A list of reports
*/
def list(
status: Option[String],
challengeId: Option[Long],
activeOnly: Boolean,
limit: Int,
page: Int
): Action[AnyContent] = Action.async { implicit request =>
this.sessionManager.authenticatedRequest { implicit user =>
val statusValue = status.map(_.trim).filter(_.nonEmpty).map { name =>
ChallengeReport
.statusFromName(name)
.getOrElse(
throw new InvalidException(
s"'$name' is not a valid report status. Expected one of ${ChallengeReport.statusNames.values
.mkString(", ")}."
)
)
}

Ok(
Json.toJson(
this.challengeReportService
.list(user, statusValue, challengeId, activeOnly, limit, page)
)
)
}
}

/**
* Retrieves a single report. Superusers only.
*
* @param id The id of the report
* @return The report, or 404
*/
def retrieve(id: Long): Action[AnyContent] = Action.async { implicit request =>
this.sessionManager.authenticatedRequest { implicit user =>
this.challengeReportService.retrieve(user, id) match {
case Some(report) => Ok(Json.toJson(report))
case None => NotFound
}
}
}

/**
* Records a triage decision on a report, so an admin can mark it actioned
* (after archiving the challenge, say) or dismissed. Superusers only.
*
* @param id The report being resolved
* @return The updated report
*/
def updateStatus(id: Long): Action[JsValue] = Action.async(bodyParsers.json) { implicit request =>
this.sessionManager.authenticatedRequest { implicit user =>
val statusName = (request.body \ "status")
.asOpt[String]
.getOrElse(
throw new InvalidException("Required 'status' field in request body not found.")
)
val status = ChallengeReport
.statusFromName(statusName)
.getOrElse(
throw new InvalidException(
s"'$statusName' is not a valid report status. Expected one of ${ChallengeReport.statusNames.values
.mkString(", ")}."
)
)
val reviewComment = (request.body \ "reviewComment").asOpt[String]

Ok(Json.toJson(this.challengeReportService.updateStatus(user, id, status, reviewComment)))
}
}
}
103 changes: 103 additions & 0 deletions app/org/maproulette/framework/model/ChallengeReport.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md).
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
package org.maproulette.framework.model

import org.joda.time.DateTime
import play.api.libs.json.JodaWrites._
import play.api.libs.json._

/**
* A report against a challenge's design -- "this challenge is poorly designed
* and is causing people to make incorrect edits" -- rather than a bug or a
* feature request. Any authenticated user can file one; only superusers can
* read them, because a report names the reporter and may carry the email
* address they volunteered for follow-up.
*
* Reports are resolved, never deleted: an admin marks one actioned (say, after
* archiving the challenge) or dismissed, so the history of what was reported
* and what was done about it stays intact.
*/
case class ChallengeReport(
override val id: Long,
challengeId: Long,
comment: String,
reportedAt: DateTime,
status: Int = ChallengeReport.STATUS_OPEN,
challengeName: Option[String] = None,
challengeIsArchived: Option[Boolean] = None,
projectId: Option[Long] = None,
projectName: Option[String] = None,
reporterId: Option[Long] = None,
reporterName: Option[String] = None,
reporterEmail: Option[String] = None,
reviewedBy: Option[Long] = None,
reviewedByName: Option[String] = None,
reviewedAt: Option[DateTime] = None,
reviewComment: Option[String] = None,
fullCount: Int = 0
) extends Identifiable

object ChallengeReport {
implicit val writes: Writes[ChallengeReport] = new Writes[ChallengeReport] {
def writes(report: ChallengeReport): JsValue =
Json.obj(
"id" -> report.id,
"challengeId" -> report.challengeId,
"challengeName" -> report.challengeName,
"challengeIsArchived" -> report.challengeIsArchived,
"projectId" -> report.projectId,
"projectName" -> report.projectName,
"reporterId" -> report.reporterId,
"reporterName" -> report.reporterName,
"reporterEmail" -> report.reporterEmail,
"comment" -> report.comment,
"status" -> report.status,
"statusName" -> statusName(report.status),
"reviewedBy" -> report.reviewedBy,
"reviewedByName" -> report.reviewedByName,
"reviewedAt" -> report.reviewedAt,
"reviewComment" -> report.reviewComment,
"reportedAt" -> report.reportedAt,
"fullCount" -> report.fullCount
)
}

val TABLE = "challenge_reports"

val FIELD_ID = "id"
val FIELD_CHALLENGE_ID = "challenge_id"
val FIELD_STATUS = "status"
val FIELD_REPORTED_AT = "reported_at"

val STATUS_OPEN = 0
val STATUS_ACTIONED = 1
val STATUS_DISMISSED = 2

val statusNames: Map[Int, String] = Map(
STATUS_OPEN -> "open",
STATUS_ACTIONED -> "actioned",
STATUS_DISMISSED -> "dismissed"
)

def statusName(status: Int): String = statusNames.getOrElse(status, "unknown")

def isValidStatus(status: Int): Boolean = statusNames.contains(status)

/**
* Resolves a status name the client sent ("open", "actioned", "dismissed")
* to its stored value, so callers never have to hardcode the integers.
*/
def statusFromName(name: String): Option[Int] =
statusNames.collectFirst { case (value, n) if n == name.trim.toLowerCase => value }

// A report has to say enough for an admin to act on it, and these bounds are
// enforced here rather than only in the form so the endpoint can be trusted
// on its own.
val MIN_COMMENT_LENGTH = 100
val MAX_COMMENT_LENGTH = 1000

// A reporter cannot pile unlimited open reports onto one challenge.
val MAX_OPEN_PER_REPORTER_PER_CHALLENGE = 1
}
Loading