Skip to content

Commit 377afd3

Browse files
committed
Move challenge reports from GitHub issues into the database
A challenge report is a complaint about a challenge's design -- "this challenge is poorly designed and is causing incorrect edits" -- as opposed to a bug or a feature request. They were filed as issues in a public GitHub repo, which meant shipping a write-scoped GitHub token to the browser and publishing the reporter's identity alongside the complaint. They live in the database instead (evolution 126), so triage happens inside MapRoulette and the reporter's contact details stay private. Reports are submitted against a challenge, listed and worked through by the people entitled to see them, and the route file is registered ahead of challenge.api so the literal /challenge/reports and /challenge/report/:id paths are not swallowed by that file's GET /challenge/:id.
1 parent fe993ec commit 377afd3

12 files changed

Lines changed: 1109 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*
2+
* Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md).
3+
* Licensed under the Apache License, Version 2.0 (see LICENSE).
4+
*/
5+
package org.maproulette.framework.controller
6+
7+
import javax.inject.Inject
8+
import org.maproulette.data.ActionManager
9+
import org.maproulette.exception.InvalidException
10+
import org.maproulette.framework.model.ChallengeReport
11+
import org.maproulette.framework.service.ChallengeReportService
12+
import org.maproulette.session.SessionManager
13+
import play.api.libs.json.{JsValue, Json}
14+
import play.api.mvc._
15+
16+
/**
17+
* Endpoints for reports filed against a challenge's design. Any authenticated
18+
* user can file one; only superusers can list or triage them.
19+
*/
20+
class ChallengeReportController @Inject() (
21+
override val sessionManager: SessionManager,
22+
override val actionManager: ActionManager,
23+
override val bodyParsers: PlayBodyParsers,
24+
challengeReportService: ChallengeReportService,
25+
components: ControllerComponents
26+
) extends AbstractController(components)
27+
with MapRouletteController {
28+
29+
/**
30+
* Files a report against a challenge. The reporter is taken from the session
31+
* rather than the request body, so a report cannot be attributed to someone
32+
* else.
33+
*
34+
* @param challengeId The challenge being reported
35+
* @return 201 Created with the new report
36+
*/
37+
def create(challengeId: Long): Action[JsValue] = Action.async(bodyParsers.json) {
38+
implicit request =>
39+
this.sessionManager.authenticatedRequest { implicit user =>
40+
val comment = (request.body \ "comment")
41+
.asOpt[String]
42+
.getOrElse(
43+
throw new InvalidException("Required 'comment' field in request body not found.")
44+
)
45+
val email = (request.body \ "email").asOpt[String]
46+
47+
Created(
48+
Json.toJson(this.challengeReportService.create(user, challengeId, comment, email))
49+
)
50+
}
51+
}
52+
53+
/**
54+
* Reports whether the requesting user already has an open report against a
55+
* challenge, so the report button can explain itself rather than letting the
56+
* user write a duplicate and be rejected on submit. Returns only the
57+
* caller's own report, so it needs no elevated permission.
58+
*
59+
* @param challengeId The challenge in question
60+
* @return The user's open report, or 204 if they have none
61+
*/
62+
def retrieveOwnOpenReport(challengeId: Long): Action[AnyContent] = Action.async {
63+
implicit request =>
64+
this.sessionManager.authenticatedRequest { implicit user =>
65+
this.challengeReportService.retrieveOwnOpenReport(user, challengeId) match {
66+
case Some(report) => Ok(Json.toJson(report))
67+
case None => NoContent
68+
}
69+
}
70+
}
71+
72+
/**
73+
* Lists reports, newest first. Superusers only.
74+
*
75+
* @param status Restrict to one triage status, by name ("open", "actioned", "dismissed")
76+
* @param challengeId Restrict to a single challenge
77+
* @param activeOnly Only reports on challenges that are neither deleted nor archived
78+
* @param limit Page size
79+
* @param page Zero-based page number
80+
* @return A list of reports
81+
*/
82+
def list(
83+
status: Option[String],
84+
challengeId: Option[Long],
85+
activeOnly: Boolean,
86+
limit: Int,
87+
page: Int
88+
): Action[AnyContent] = Action.async { implicit request =>
89+
this.sessionManager.authenticatedRequest { implicit user =>
90+
val statusValue = status.map(_.trim).filter(_.nonEmpty).map { name =>
91+
ChallengeReport
92+
.statusFromName(name)
93+
.getOrElse(
94+
throw new InvalidException(
95+
s"'$name' is not a valid report status. Expected one of ${ChallengeReport.statusNames.values
96+
.mkString(", ")}."
97+
)
98+
)
99+
}
100+
101+
Ok(
102+
Json.toJson(
103+
this.challengeReportService
104+
.list(user, statusValue, challengeId, activeOnly, limit, page)
105+
)
106+
)
107+
}
108+
}
109+
110+
/**
111+
* Retrieves a single report. Superusers only.
112+
*
113+
* @param id The id of the report
114+
* @return The report, or 404
115+
*/
116+
def retrieve(id: Long): Action[AnyContent] = Action.async { implicit request =>
117+
this.sessionManager.authenticatedRequest { implicit user =>
118+
this.challengeReportService.retrieve(user, id) match {
119+
case Some(report) => Ok(Json.toJson(report))
120+
case None => NotFound
121+
}
122+
}
123+
}
124+
125+
/**
126+
* Records a triage decision on a report, so an admin can mark it actioned
127+
* (after archiving the challenge, say) or dismissed. Superusers only.
128+
*
129+
* @param id The report being resolved
130+
* @return The updated report
131+
*/
132+
def updateStatus(id: Long): Action[JsValue] = Action.async(bodyParsers.json) { implicit request =>
133+
this.sessionManager.authenticatedRequest { implicit user =>
134+
val statusName = (request.body \ "status")
135+
.asOpt[String]
136+
.getOrElse(
137+
throw new InvalidException("Required 'status' field in request body not found.")
138+
)
139+
val status = ChallengeReport
140+
.statusFromName(statusName)
141+
.getOrElse(
142+
throw new InvalidException(
143+
s"'$statusName' is not a valid report status. Expected one of ${ChallengeReport.statusNames.values
144+
.mkString(", ")}."
145+
)
146+
)
147+
val reviewComment = (request.body \ "reviewComment").asOpt[String]
148+
149+
Ok(Json.toJson(this.challengeReportService.updateStatus(user, id, status, reviewComment)))
150+
}
151+
}
152+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md).
3+
* Licensed under the Apache License, Version 2.0 (see LICENSE).
4+
*/
5+
package org.maproulette.framework.model
6+
7+
import org.joda.time.DateTime
8+
import play.api.libs.json.JodaWrites._
9+
import play.api.libs.json._
10+
11+
/**
12+
* A report against a challenge's design -- "this challenge is poorly designed
13+
* and is causing people to make incorrect edits" -- rather than a bug or a
14+
* feature request. Any authenticated user can file one; only superusers can
15+
* read them, because a report names the reporter and may carry the email
16+
* address they volunteered for follow-up.
17+
*
18+
* Reports are resolved, never deleted: an admin marks one actioned (say, after
19+
* archiving the challenge) or dismissed, so the history of what was reported
20+
* and what was done about it stays intact.
21+
*/
22+
case class ChallengeReport(
23+
override val id: Long,
24+
challengeId: Long,
25+
comment: String,
26+
reportedAt: DateTime,
27+
status: Int = ChallengeReport.STATUS_OPEN,
28+
challengeName: Option[String] = None,
29+
challengeIsArchived: Option[Boolean] = None,
30+
projectId: Option[Long] = None,
31+
projectName: Option[String] = None,
32+
reporterId: Option[Long] = None,
33+
reporterName: Option[String] = None,
34+
reporterEmail: Option[String] = None,
35+
reviewedBy: Option[Long] = None,
36+
reviewedByName: Option[String] = None,
37+
reviewedAt: Option[DateTime] = None,
38+
reviewComment: Option[String] = None,
39+
fullCount: Int = 0
40+
) extends Identifiable
41+
42+
object ChallengeReport {
43+
implicit val writes: Writes[ChallengeReport] = new Writes[ChallengeReport] {
44+
def writes(report: ChallengeReport): JsValue =
45+
Json.obj(
46+
"id" -> report.id,
47+
"challengeId" -> report.challengeId,
48+
"challengeName" -> report.challengeName,
49+
"challengeIsArchived" -> report.challengeIsArchived,
50+
"projectId" -> report.projectId,
51+
"projectName" -> report.projectName,
52+
"reporterId" -> report.reporterId,
53+
"reporterName" -> report.reporterName,
54+
"reporterEmail" -> report.reporterEmail,
55+
"comment" -> report.comment,
56+
"status" -> report.status,
57+
"statusName" -> statusName(report.status),
58+
"reviewedBy" -> report.reviewedBy,
59+
"reviewedByName" -> report.reviewedByName,
60+
"reviewedAt" -> report.reviewedAt,
61+
"reviewComment" -> report.reviewComment,
62+
"reportedAt" -> report.reportedAt,
63+
"fullCount" -> report.fullCount
64+
)
65+
}
66+
67+
val TABLE = "challenge_reports"
68+
69+
val FIELD_ID = "id"
70+
val FIELD_CHALLENGE_ID = "challenge_id"
71+
val FIELD_STATUS = "status"
72+
val FIELD_REPORTED_AT = "reported_at"
73+
74+
val STATUS_OPEN = 0
75+
val STATUS_ACTIONED = 1
76+
val STATUS_DISMISSED = 2
77+
78+
val statusNames: Map[Int, String] = Map(
79+
STATUS_OPEN -> "open",
80+
STATUS_ACTIONED -> "actioned",
81+
STATUS_DISMISSED -> "dismissed"
82+
)
83+
84+
def statusName(status: Int): String = statusNames.getOrElse(status, "unknown")
85+
86+
def isValidStatus(status: Int): Boolean = statusNames.contains(status)
87+
88+
/**
89+
* Resolves a status name the client sent ("open", "actioned", "dismissed")
90+
* to its stored value, so callers never have to hardcode the integers.
91+
*/
92+
def statusFromName(name: String): Option[Int] =
93+
statusNames.collectFirst { case (value, n) if n == name.trim.toLowerCase => value }
94+
95+
// A report has to say enough for an admin to act on it, and these bounds are
96+
// enforced here rather than only in the form so the endpoint can be trusted
97+
// on its own.
98+
val MIN_COMMENT_LENGTH = 100
99+
val MAX_COMMENT_LENGTH = 1000
100+
101+
// A reporter cannot pile unlimited open reports onto one challenge.
102+
val MAX_OPEN_PER_REPORTER_PER_CHALLENGE = 1
103+
}

0 commit comments

Comments
 (0)