Skip to content

Commit

Permalink
Add scoring logic for child workouts
Browse files Browse the repository at this point in the history
  • Loading branch information
khy committed Dec 2, 2016
1 parent d78594e commit 7d71100
Show file tree
Hide file tree
Showing 3 changed files with 161 additions and 53 deletions.
4 changes: 2 additions & 2 deletions modules/apis/workouts/app/db/Workout.scala
Expand Up @@ -19,7 +19,7 @@ case class WorkoutRecord(
deletedByAccessToken: Option[UUID]
)

class WorkoutTable(tag: Tag)
class WorkoutsTable(tag: Tag)
extends Table[WorkoutRecord](tag, "workouts")
with SchemaData[WorkoutRecord]
with AuditData[WorkoutRecord]
Expand All @@ -32,4 +32,4 @@ class WorkoutTable(tag: Tag)
deletedByAccessToken) <> (WorkoutRecord.tupled, WorkoutRecord.unapply)
}

object Workouts extends TableQuery(new WorkoutTable(_))
object Workouts extends TableQuery(new WorkoutsTable(_))
135 changes: 85 additions & 50 deletions modules/apis/workouts/app/services/WorkoutsService.scala
Expand Up @@ -42,10 +42,23 @@ class WorkoutsService(
)
}

def findWorkouts(
guids: Option[Seq[UUID]] = None
): Future[Seq[WorkoutRecord]] = {
var query: Query[WorkoutsTable, WorkoutRecord, Seq] = Workouts

guids.foreach { guids =>
query = query.filter(_.guid inSet guids)
}

db.run(query.result)
}

def addWorkout(
workout: core.Workout,
accessToken: AccessToken
)(implicit ec: ExecutionContext): Future[Validation[WorkoutRecord]] = {
// Find all movements referenced in the workout
def getMovementGuids(subTasks: Seq[core.SubTask]): Seq[UUID] = {
if (!subTasks.isEmpty) {
subTasks.flatMap(_.movement.map(_.guid)) ++
Expand All @@ -59,67 +72,89 @@ class WorkoutsService(
getMovementGuids(workout.tasks.getOrElse(Nil))

val movementsQuery = Movements.filter(_.guid.inSet(movementGuids))
val futMovements = db.run(movementsQuery.result)

// Find the "ancestry" of the workout (limited to 2 for now)
val futAncestry = for {
optParent <- workout.parentGuid.map { parentGuid =>
findWorkouts(guids = Some(Seq(parentGuid))).flatMap(db2api).map(_.headOption)
}.getOrElse(Future.successful(None))
optGrandParent <- optParent.flatMap(_.parentGuid).map { grandParentGuid =>
findWorkouts(guids = Some(Seq(grandParentGuid))).flatMap(db2api).map(_.headOption)
}.getOrElse(Future.successful(None))
} yield Seq(optParent, optGrandParent).flatten

for {
movements <- futMovements
ancestry <- futAncestry
result <- {
var errors = Seq.empty[Errors]

// Validate that all workout GUIDs are known
val badGuids = movementGuids.filterNot { movementGuid =>
movements.map(_.guid).contains(movementGuid)
}

db.run(movementsQuery.result).flatMap { movements =>
var errors = Seq.empty[Errors]

// Validate that all workout GUIDs are known
val badGuids = movementGuids.filterNot { movementGuid =>
movements.map(_.guid).contains(movementGuid)
}

if (badGuids.size > 0) {
errors = errors :+ Errors.scalar(badGuids.map { badGuid =>
Message(
key = "unknownWorkoutGuid",
details = "guid" -> badGuid.toString
)
})
}
if (badGuids.size > 0) {
errors = errors :+ Errors.scalar(badGuids.map { badGuid =>
Message(
key = "unknownWorkoutGuid",
details = "guid" -> badGuid.toString
)
})
}

val scores = workout.score.toSeq ++
workout.movement.flatMap(_.score).toSeq ++
workout.tasks.map(_.flatMap(_.movement.flatMap(_.score))).getOrElse(Nil)

// Validate that there's exactly one score
if (scores.size == 0) {
errors = errors :+ Errors.scalar(Seq(Message(key = "noScoreSpecified")))
}
val scores = workout.score.toSeq ++
workout.movement.flatMap(_.score).toSeq ++
workout.tasks.map(_.flatMap(_.movement.flatMap(_.score))).getOrElse(Nil)

if (scores.size > 1) {
errors = errors :+ Errors.scalar(Seq(Message(key = "multipleScoresSpecified")))
}
// If the workout does not have a parent,
if (ancestry.length == 0) {
// it must have exactly one score,
if (scores.size == 0) {
errors = errors :+ Errors.scalar(Seq(Message(key = "noScoreSpecified")))
} else if (scores.size > 1) {
errors = errors :+ Errors.scalar(Seq(Message(key = "multipleScoresSpecified")))
}

// Validate that, if there's a top-level score, that it's either 'time' or 'reps'
workout.score.foreach { topLevelScore =>
if (topLevelScore != "time" && topLevelScore != "reps") {
errors = errors :+ Errors.scalar(Seq(Message(key = "invalidTopLevelScore", "score" -> topLevelScore)))
// and the top-level score, if it exists, must be either 'time' or 'reps'.
workout.score.foreach { topLevelScore =>
if (topLevelScore != "time" && topLevelScore != "reps") {
errors = errors :+ Errors.scalar(Seq(Message(key = "invalidTopLevelScore", "score" -> topLevelScore)))
}
}
} else {
// If the workout does have a parent, it cannot have a score.
if (scores.size > 0) {
errors = errors :+ Errors.scalar(Seq(Message(key = "scoreSpecifiedByChild")))
}
}
}

if (!errors.isEmpty) {
Future.successful(Validation.failure(errors))
} else {
val projection = Workouts.map { r =>
(r.guid, r.schemaVersionMajor, r.schemaVersionMinor, r.json,
r.createdByAccount, r.createdByAccessToken)
}.returning(Workouts.map(_.guid))

val insert = projection += ((UUID.randomUUID, 1, 0, Json.toJson(workout),
accessToken.resourceOwner.guid, accessToken.guid))

db.run(insert).flatMap { guid =>
val query = Workouts.filter(_.guid === guid)
db.run(query.result).map { workouts =>
workouts.headOption.map { workout =>
Validation.success(workout)
}.getOrElse {
throw new ResourceNotFound("workout", guid)
if (!errors.isEmpty) {
Future.successful(Validation.failure(errors))
} else {
val projection = Workouts.map { r =>
(r.guid, r.schemaVersionMajor, r.schemaVersionMinor, r.json,
r.createdByAccount, r.createdByAccessToken)
}.returning(Workouts.map(_.guid))

val insert = projection += ((UUID.randomUUID, 1, 0, Json.toJson(workout),
accessToken.resourceOwner.guid, accessToken.guid))

db.run(insert).flatMap { guid =>
val query = Workouts.filter(_.guid === guid)
db.run(query.result).map { workouts =>
workouts.headOption.map { workout =>
Validation.success(workout)
}.getOrElse {
throw new ResourceNotFound("workout", guid)
}
}
}
}
}
}
} yield result
}

}
75 changes: 74 additions & 1 deletion modules/apis/workouts/test/integration/workout/ScoreSpec.scala
Expand Up @@ -17,7 +17,7 @@ class ScoreSpec extends IntegrationSpec {

"POST /workouts" must {

"reject a workout that has no score" in {
"reject a workout that has no score, and no parent" in {
val response = await { request("/workouts").post(Json.parse(s"""
{
"name": "1 Rep",
Expand Down Expand Up @@ -99,6 +99,79 @@ class ScoreSpec extends IntegrationSpec {
message.details("score") mustBe "Barbell Weight"
}

"accept a workout that has no score, but has a parent" in {
val parentResponse = await { request("/workouts").post(Json.parse(s"""
{
"name": "100 Reps",
"score": "time",
"reps": 100,
"movement": {
"guid": "${movement.guid}"
}
}
""")) }
val parent = parentResponse.json.as[Workout]

val childResponse = await { request("/workouts").post(Json.parse(s"""
{
"parentGuid": "${parent.guid}",
"movement": {
"guid": "${movement.guid}",
"variables": [
{
"name": "Barbell Weight",
"measurement": {
"unitOfMeasure": "lbs",
"value": 95
}
}
]
}
}
""")) }

childResponse.status mustBe CREATED
}

"reject a workout that has a score and a parent" in {
val parentResponse = await { request("/workouts").post(Json.parse(s"""
{
"name": "100 Reps",
"score": "time",
"reps": 100,
"movement": {
"guid": "${movement.guid}"
}
}
""")) }

val parent = parentResponse.json.as[Workout]

val childResponse = await { request("/workouts").post(Json.parse(s"""
{
"parentGuid": "${parent.guid}",
"score": "time",
"movement": {
"guid": "${movement.guid}",
"variables": [
{
"name": "Barbell Weight",
"measurement": {
"unitOfMeasure": "lbs",
"value": 95
}
}
]
}
}
""")) }

childResponse.status mustBe BAD_REQUEST
val scalarErrors = childResponse.json.as[Seq[Errors]].head
val message = scalarErrors.messages.head
message.key mustBe "scoreSpecifiedByChild"
}

}

}

0 comments on commit 7d71100

Please sign in to comment.