Add climbing level to database

I spent a lot of time trying to make this use enums for the entire
data path. Unfortunately, I ran into a few issues. Firstly, I couldn't
figure out how make our Go SQL code happy with postgresql enums. I
kept getting errors about `unknown oid`. Secondly, I couldn't figure
out how to de-duplicate the enum between `submit_data_scouting.fbs`
and `request_data_scouting_response.fbs`. The generated Go code
doesn't import the dependency properly.

All this turned into an enum at the flatbuffer and TypeScript level,
but just an integer at the Go/postgres level.

A future patch can deal with this. Perhaps it'd be better to ignore
this altogether and just switch to a library like Gorm.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: Id6cbb5502fd77f3107514b8d7cb9df2923a9d5f9
diff --git a/scouting/db/db.go b/scouting/db/db.go
index d9bebc9..4b40dbc 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -27,7 +27,15 @@
 	// TODO(phil): Re-order auto and teleop fields so auto comes first.
 	ShotsMissed, UpperGoalShots, LowerGoalShots                  int32
 	ShotsMissedAuto, UpperGoalAuto, LowerGoalAuto, PlayedDefense int32
-	Climbing                                                     int32
+	// Climbing level:
+	// 0 -> "NoAttempt"
+	// 1 -> "Failed"
+	// 2 -> "FailedWithPlentyOfTime"
+	// 3 -> "Low"
+	// 4 -> "Medium"
+	// 5 -> "High"
+	// 6 -> "Transversal"
+	Climbing int32
 	// The username of the person who collected these statistics.
 	// "unknown" if submitted without logging in.
 	// Empty if the stats have not yet been collected.
@@ -50,6 +58,7 @@
 	if err != nil {
 		return nil, errors.New(fmt.Sprint("Failed to connect to postgres: ", err))
 	}
+
 	statement, err := database.Prepare("CREATE TABLE IF NOT EXISTS matches (" +
 		"id SERIAL PRIMARY KEY, " +
 		"MatchNumber INTEGER, " +
diff --git a/scouting/webserver/requests/debug/cli/cli_test.py b/scouting/webserver/requests/debug/cli/cli_test.py
index 8bebc69..718b2ce 100644
--- a/scouting/webserver/requests/debug/cli/cli_test.py
+++ b/scouting/webserver/requests/debug/cli/cli_test.py
@@ -75,7 +75,7 @@
             "upper_goal_tele": 14,
             "lower_goal_tele": 15,
             "defense_rating": 3,
-            "climbing": 1,
+            "climb_level": "Medium",
         })
         exit_code, _, stderr = run_debug_cli(["-submitDataScouting", json_path])
         self.assertEqual(exit_code, 0, stderr)
@@ -97,14 +97,14 @@
             UpperGoalTele: (int32) 14,
             LowerGoalTele: (int32) 15,
             DefenseRating: (int32) 3,
-            Climbing: (int32) 1,
             CollectedBy: (string) (len=9) "debug_cli",
             AutoBall1: (bool) true,
             AutoBall2: (bool) false,
             AutoBall3: (bool) false,
             AutoBall4: (bool) false,
             AutoBall5: (bool) true,
-            StartingQuadrant: (int32) 3
+            StartingQuadrant: (int32) 3,
+            ClimbLevel: (request_data_scouting_response.ClimbLevel) Medium
             }"""), stdout)
 
     def test_request_all_matches(self):
diff --git a/scouting/webserver/requests/messages/request_data_scouting_response.fbs b/scouting/webserver/requests/messages/request_data_scouting_response.fbs
index 7d91cb7..d85dfbb 100644
--- a/scouting/webserver/requests/messages/request_data_scouting_response.fbs
+++ b/scouting/webserver/requests/messages/request_data_scouting_response.fbs
@@ -1,5 +1,18 @@
 namespace scouting.webserver.requests;
 
+// TODO(phil): Deduplicate with submit_data_scouting.
+// At the moment, our Go setup doesn't handle includes.
+enum ClimbLevel : byte {
+    NoAttempt = 0,
+    Failed,
+    // Tried for more than 10 seconds and failed.
+    FailedWithPlentyOfTime,
+    Low,
+    Medium,
+    High,
+    Transversal,
+}
+
 table Stats {
     team:int (id: 0);
     match:int (id: 1);
@@ -11,7 +24,10 @@
     upper_goal_tele:int (id:6);
     lower_goal_tele:int (id:7);
     defense_rating:int (id:8);
-    climbing:int (id:9);
+
+    climbing:int (id:9, deprecated);
+    climb_level:ClimbLevel (id:17);
+
     collected_by:string (id:10);
 
     auto_ball_1:bool (id:11);
diff --git a/scouting/webserver/requests/messages/submit_data_scouting.fbs b/scouting/webserver/requests/messages/submit_data_scouting.fbs
index d3d87e2..a9c44a2 100644
--- a/scouting/webserver/requests/messages/submit_data_scouting.fbs
+++ b/scouting/webserver/requests/messages/submit_data_scouting.fbs
@@ -1,5 +1,18 @@
 namespace scouting.webserver.requests;
 
+// TODO(phil): Deduplicate with request_scouting_data_response.
+// At the moment, our Go setup doesn't handle includes.
+enum ClimbLevel : byte {
+    NoAttempt = 0,
+    Failed,
+    // Tried for more than 10 seconds and failed.
+    FailedWithPlentyOfTime,
+    Low,
+    Medium,
+    High,
+    Transversal,
+}
+
 table SubmitDataScouting {
     team:int (id: 0);
     match:int (id: 1);
@@ -18,9 +31,10 @@
     // TODO: Document what the different values mean. E.g. 0 means no defense
     // played against this robot?
     defense_received_rating:int (id:10);
-    // The rating that this robot gets for its climbing.
-    // TODO: Change into an enum to make the different values self-documenting.
-    climbing:int (id:9);
+
+    climbing:int (id:9, deprecated);
+    climb_level:ClimbLevel (id:17);
+
     auto_ball_1:bool (id:11);
     auto_ball_2:bool (id:12);
     auto_ball_3:bool (id:13);
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
index 13d7396..118fafb 100644
--- a/scouting/webserver/requests/requests.go
+++ b/scouting/webserver/requests/requests.go
@@ -156,7 +156,7 @@
 		UpperGoalShots:  request.UpperGoalTele(),
 		LowerGoalShots:  request.LowerGoalTele(),
 		PlayedDefense:   request.DefenseRating(),
-		Climbing:        request.Climbing(),
+		Climbing:        int32(request.ClimbLevel()),
 		CollectedBy:     username,
 	}
 
@@ -344,7 +344,7 @@
 			UpperGoalTele:    stat.UpperGoalShots,
 			LowerGoalTele:    stat.LowerGoalShots,
 			DefenseRating:    stat.PlayedDefense,
-			Climbing:         stat.Climbing,
+			ClimbLevel:       request_data_scouting_response.ClimbLevel(stat.Climbing),
 			CollectedBy:      stat.CollectedBy,
 		})
 	}
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index 12d918b..8165c7d 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -97,7 +97,7 @@
 		UpperGoalTele:    9971,
 		LowerGoalTele:    9971,
 		DefenseRating:    9971,
-		Climbing:         9971,
+		ClimbLevel:       submit_data_scouting.ClimbLevelLow,
 	}).Pack(builder))
 
 	response, err := debug.SubmitDataScouting("http://localhost:8080", builder.FinishedBytes())
@@ -231,7 +231,7 @@
 				AutoBallPickedUp: [5]bool{true, false, false, false, true},
 				ShotsMissed:      1, UpperGoalShots: 2, LowerGoalShots: 3,
 				ShotsMissedAuto: 4, UpperGoalAuto: 5, LowerGoalAuto: 6,
-				PlayedDefense: 7, Climbing: 8,
+				PlayedDefense: 7, Climbing: 2,
 				CollectedBy: "john",
 			},
 			{
@@ -240,7 +240,7 @@
 				AutoBallPickedUp: [5]bool{false, false, true, false, false},
 				ShotsMissed:      2, UpperGoalShots: 3, LowerGoalShots: 4,
 				ShotsMissedAuto: 5, UpperGoalAuto: 6, LowerGoalAuto: 7,
-				PlayedDefense: 8, Climbing: 9,
+				PlayedDefense: 8, Climbing: 4,
 				CollectedBy: "andrea",
 			},
 		},
@@ -260,33 +260,27 @@
 
 	expected := request_data_scouting_response.RequestDataScoutingResponseT{
 		StatsList: []*request_data_scouting_response.StatsT{
-			// Team, Match,
-			// MissedShotsAuto, UpperGoalAuto, LowerGoalAuto,
-			// MissedShotsTele, UpperGoalTele, LowerGoalTele,
-			// DefenseRating, Climbing,
-			// CollectedBy,
-			// AutoBall1, AutoBall2, AutoBall3,
-			// AutoBall4, AutoBall5,
-			// StartingQuadrant,
 			{
-				971, 1,
-				4, 5, 6,
-				1, 2, 3,
-				7, 8,
-				"john",
-				true, false, false,
-				false, true,
-				1,
+				Team: 971, Match: 1,
+				MissedShotsAuto: 4, UpperGoalAuto: 5, LowerGoalAuto: 6,
+				MissedShotsTele: 1, UpperGoalTele: 2, LowerGoalTele: 3,
+				DefenseRating: 7,
+				CollectedBy:   "john",
+				AutoBall1:     true, AutoBall2: false, AutoBall3: false,
+				AutoBall4: false, AutoBall5: true,
+				StartingQuadrant: 1,
+				ClimbLevel:       request_data_scouting_response.ClimbLevelFailedWithPlentyOfTime,
 			},
 			{
-				972, 1,
-				5, 6, 7,
-				2, 3, 4,
-				8, 9,
-				"andrea",
-				false, false, true,
-				false, false,
-				2,
+				Team: 972, Match: 1,
+				MissedShotsAuto: 5, UpperGoalAuto: 6, LowerGoalAuto: 7,
+				MissedShotsTele: 2, UpperGoalTele: 3, LowerGoalTele: 4,
+				DefenseRating: 8,
+				CollectedBy:   "andrea",
+				AutoBall1:     false, AutoBall2: false, AutoBall3: true,
+				AutoBall4: false, AutoBall5: false,
+				StartingQuadrant: 2,
+				ClimbLevel:       request_data_scouting_response.ClimbLevelMedium,
 			},
 		},
 	}
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index d067c3e..357d6b7 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -2,13 +2,11 @@
 import {FormsModule} from '@angular/forms';
 import {Builder, ByteBuffer} from 'flatbuffers';
 import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
-import {SubmitDataScouting} from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_generated';
+import {ClimbLevel, SubmitDataScouting} from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_generated';
 import {SubmitDataScoutingResponse} from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_response_generated';
 
 type Section = 'Team Selection'|'Auto'|'TeleOp'|'Climb'|'Other'|
     'Review and Submit'|'Success';
-type Level = 'NoAttempt'|'Failed'|'FailedWithPlentyOfTime'|'Low'|'Medium'|
-    'High'|'Transversal';
 
 @Component({
   selector: 'app-entry',
@@ -16,6 +14,10 @@
   styleUrls: ['../common.css', './entry.component.css']
 })
 export class EntryComponent {
+  // Re-export the type here so that we can use it in the `[value]` attribute
+  // of radio buttons.
+  readonly ClimbLevel = ClimbLevel;
+
   section: Section = 'Team Selection';
   @Output() switchTabsEvent = new EventEmitter<string>();
   @Input() matchNumber: number = 1;
@@ -28,7 +30,7 @@
   teleShotsMissed: number = 0;
   defensePlayedOnScore: number = 0;
   defensePlayedScore: number = 0;
-  level: Level = 'NoAttempt';
+  level: ClimbLevel = ClimbLevel.NoAttempt;
   ball1: boolean = false;
   ball2: boolean = false;
   ball3: boolean = false;
@@ -56,6 +58,7 @@
     } else if (this.section === 'Other') {
       this.section = 'Review and Submit';
     } else if (this.section === 'Review and Submit') {
+
       this.submitDataScouting();
       return;
     } else if (this.section === 'Success') {
@@ -109,10 +112,8 @@
     SubmitDataScouting.addAutoBall4(builder, this.ball4);
     SubmitDataScouting.addAutoBall5(builder, this.ball5);
     SubmitDataScouting.addStartingQuadrant(builder, this.quadrant);
-
     // TODO(phil): Add support for defensePlayedOnScore.
-    // TODO(phil): Fix the Climbing score.
-    SubmitDataScouting.addClimbing(builder, 1);
+    SubmitDataScouting.addClimbLevel(builder, this.level);
     builder.finish(SubmitDataScouting.endSubmitDataScouting(builder));
 
     const buffer = builder.asUint8Array();
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index b4d81c0..45a8a62 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -1,12 +1,21 @@
-import {NgModule} from '@angular/core';
+import {NgModule, Pipe, PipeTransform} from '@angular/core';
 import {CommonModule} from '@angular/common';
 import {FormsModule} from '@angular/forms';
 
 import {CounterButtonModule} from '../counter_button/counter_button.module';
 import {EntryComponent} from './entry.component';
 
+import {ClimbLevel} from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_generated';
+
+@Pipe({name: 'levelToString'})
+export class LevelToStringPipe implements PipeTransform {
+  transform(level: ClimbLevel): string {
+    return ClimbLevel[level];
+  }
+}
+
 @NgModule({
-  declarations: [EntryComponent],
+  declarations: [EntryComponent, LevelToStringPipe],
   exports: [EntryComponent],
   imports: [CommonModule, FormsModule, CounterButtonModule],
 })
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 5849f3b..ead9f83 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -69,19 +69,19 @@
 
     <div *ngSwitchCase="'Climb'" id="climb" class="container-fluid">
         <form>
-            <input [(ngModel)]="level" type="radio" name="level" id="no_attempt" value="NoAttempt">
+            <input [(ngModel)]="level" type="radio" name="level" id="no_attempt" [value]="ClimbLevel.NoAttempt">
             <label for="no_attempt">No climbing attempt</label><br>
-            <input [(ngModel)]="level" type="radio" name="level" id="low" value="Low">
+            <input [(ngModel)]="level" type="radio" name="level" id="low" [value]="ClimbLevel.Low">
             <label for="low">Low</label><br>
-            <input [(ngModel)]="level" type="radio" name="level" id="medium" value="Medium">
+            <input [(ngModel)]="level" type="radio" name="level" id="medium" [value]="ClimbLevel.Medium">
             <label for="medium">Medium</label><br>
-            <input [(ngModel)]="level" type="radio" name="level" id="high" value="High">
+            <input [(ngModel)]="level" type="radio" name="level" id="high" [value]="ClimbLevel.High">
             <label for="high">High</label><br>
-            <input [(ngModel)]="level" type="radio" name="level" id="transversal" value="Transversal">
+            <input [(ngModel)]="level" type="radio" name="level" id="transversal" [value]="ClimbLevel.Transversal">
             <label for="transversal">Transversal</label><br>
-            <input [(ngModel)]="level" type="radio" name="level" id="failed" value="Failed">
+            <input [(ngModel)]="level" type="radio" name="level" id="failed" [value]="ClimbLevel.Failed">
             <label for="failed">Failed</label><br>
-            <input [(ngModel)]="level" type="radio" name="level" id="failed_with_plenty_of_time" value="FailedWithPlentyOfTime">
+            <input [(ngModel)]="level" type="radio" name="level" id="failed_with_plenty_of_time" [value]="ClimbLevel.FailedWithPlentyOfTime">
             <label for="failed_with_plenty_of_time">Failed (attempted with more than 10 seconds left)</label><br>
         </form>
         <div class="row">
@@ -177,7 +177,7 @@
 
         <h4>Climb</h4>
         <ul>
-            <li>Level: {{level}}</li>
+            <li>Level: {{level | levelToString}}</li>
         </ul>
 
         <h4>Other</h4>