Add Pit Scouting Tab

Signed-off-by: Emily Markova <emily.markova@gmail.com>
Change-Id: Iede446546e20f2915bb53e134050b5025976da36
diff --git a/scouting/db/BUILD b/scouting/db/BUILD
index 154cab6..9447a5f 100644
--- a/scouting/db/BUILD
+++ b/scouting/db/BUILD
@@ -18,9 +18,7 @@
     name = "db_test",
     size = "small",
     srcs = ["db_test.go"],
-    data = [
-        "//scouting/db/testdb_server",
-    ],
+    data = ["//scouting/db/testdb_server"],
     embed = [":db"],
     target_compatible_with = ["@platforms//cpu:x86_64"],
     deps = ["@com_github_davecgh_go_spew//spew"],
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 1a43634..578edb6 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -1,6 +1,7 @@
 package db
 
 import (
+	"crypto/sha256"
 	"errors"
 	"fmt"
 	"gorm.io/driver/postgres"
@@ -27,6 +28,19 @@
 	R1scouter, R2scouter, R3scouter, B1scouter, B2scouter, B3scouter string
 }
 
+type PitImage struct {
+	TeamNumber string `gorm:"primaryKey"`
+	CheckSum   string `gorm:"primaryKey"`
+	ImagePath  string
+	ImageData  []byte
+}
+
+type RequestedPitImage struct {
+	TeamNumber string
+	CheckSum   string `gorm:"primaryKey"`
+	ImagePath  string
+}
+
 type Stats2023 struct {
 	// This is set to `true` for "pre-scouted" matches. This means that the
 	// match information is unlikely to correspond with an entry in the
@@ -125,7 +139,7 @@
 		return nil, errors.New(fmt.Sprint("Failed to connect to postgres: ", err))
 	}
 
-	err = database.AutoMigrate(&TeamMatch{}, &Shift{}, &Stats2023{}, &Action{}, &NotesData{}, &Ranking{}, &DriverRankingData{}, &ParsedDriverRankingData{})
+	err = database.AutoMigrate(&TeamMatch{}, &Shift{}, &Stats2023{}, &Action{}, &PitImage{}, &NotesData{}, &Ranking{}, &DriverRankingData{}, &ParsedDriverRankingData{})
 	if err != nil {
 		database.Delete()
 		return nil, errors.New(fmt.Sprint("Failed to create/migrate tables: ", err))
@@ -167,6 +181,11 @@
 	return result.Error
 }
 
+func (database *Database) AddPitImage(p PitImage) error {
+	result := database.Create(&p)
+	return result.Error
+}
+
 func (database *Database) AddToStats2023(s Stats2023) error {
 	if !s.PreScouting {
 		matches, err := database.QueryMatchesString(s.TeamNumber)
@@ -250,6 +269,12 @@
 	return actions, result.Error
 }
 
+func (database *Database) ReturnPitImages() ([]PitImage, error) {
+	var images []PitImage
+	result := database.Find(&images)
+	return images, result.Error
+}
+
 func (database *Database) ReturnStats2023() ([]Stats2023, error) {
 	var stats2023 []Stats2023
 	result := database.Find(&stats2023)
@@ -279,6 +304,28 @@
 	return matches, result.Error
 }
 
+func (database *Database) QueryPitImages(teamNumber_ string) ([]RequestedPitImage, error) {
+	var requestedPitImages []RequestedPitImage
+	result := database.Model(&PitImage{}).
+		Where("team_number = $1", teamNumber_).
+		Find(&requestedPitImages)
+
+	return requestedPitImages, result.Error
+}
+
+func (database *Database) QueryPitImageByChecksum(checksum_ string) (PitImage, error) {
+	var pitImage PitImage
+	result := database.
+		Where("check_sum = $1", checksum_).
+		Find(&pitImage)
+	return pitImage, result.Error
+}
+
+func ComputeSha256FromByteArray(arr []byte) string {
+	sum := sha256.Sum256(arr)
+	return fmt.Sprintf("%x", sum)
+}
+
 func (database *Database) QueryMatchesString(teamNumber_ string) ([]TeamMatch, error) {
 	var matches []TeamMatch
 	result := database.
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index d49e649..94dce7e 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -609,6 +609,82 @@
 	}
 }
 
+func TestQueryPitImages(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	testDatabase := []PitImage{
+		PitImage{
+			TeamNumber: "723", CheckSum: "8be8h9829hf98wp",
+			ImagePath: "image1.jpg", ImageData: []byte{14, 15, 32, 54},
+		},
+		PitImage{
+			TeamNumber: "237", CheckSum: "br78232b6r7iaa",
+			ImagePath: "bot.png", ImageData: []byte{32, 54, 23, 00},
+		},
+		PitImage{
+			TeamNumber: "125A", CheckSum: "b63c728bqiq8a73",
+			ImagePath: "file123.jpeg", ImageData: []byte{32, 05, 01, 28},
+		},
+	}
+
+	for i := 0; i < len(testDatabase); i++ {
+		err := fixture.db.AddPitImage(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add pit image", i))
+	}
+
+	correct := []RequestedPitImage{
+		RequestedPitImage{
+			TeamNumber: "723", CheckSum: "8be8h9829hf98wp",
+			ImagePath: "image1.jpg",
+		},
+	}
+
+	got, err := fixture.db.QueryPitImages("723")
+	check(t, err, "Failed to query shift for team 723")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
+func TestQueryPitImageByChecksum(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	testDatabase := []PitImage{
+		PitImage{
+			TeamNumber: "723", CheckSum: "8be8h9829hf98wp",
+			ImagePath: "image1.jpg", ImageData: []byte{05, 32, 00, 74, 28},
+		},
+		PitImage{
+			TeamNumber: "237", CheckSum: "br78232b6r7iaa",
+			ImagePath: "bot.png", ImageData: []byte{32, 54, 23, 00},
+		},
+		PitImage{
+			TeamNumber: "125A", CheckSum: "b63c728bqiq8a73",
+			ImagePath: "file123.jpeg", ImageData: []byte{32, 05, 01, 28},
+		},
+	}
+
+	for i := 0; i < len(testDatabase); i++ {
+		err := fixture.db.AddPitImage(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add pit image", i))
+	}
+
+	correctPitImage := PitImage{
+		TeamNumber: "125A", CheckSum: "b63c728bqiq8a73",
+		ImagePath: "file123.jpeg", ImageData: []byte{32, 05, 01, 28},
+	}
+
+	got, err := fixture.db.QueryPitImageByChecksum("b63c728bqiq8a73")
+	check(t, err, "Failed to query shift for checksum 'b63c728bqiq8a73'")
+
+	if !reflect.DeepEqual(correctPitImage, got) {
+		t.Fatalf("Got %#v,\nbut expected %#v.", got, correctPitImage)
+	}
+}
+
 func TestQueryRankingsDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
diff --git a/scouting/webserver/main.go b/scouting/webserver/main.go
index c5bed2c..d655708 100644
--- a/scouting/webserver/main.go
+++ b/scouting/webserver/main.go
@@ -133,7 +133,7 @@
 	defer database.Delete()
 
 	scoutingServer := server.NewScoutingServer()
-	static.ServePages(scoutingServer, *dirPtr)
+	static.ServePages(scoutingServer, *dirPtr, database)
 	requests.HandleRequests(database, scoutingServer)
 	scoutingServer.Start(*portPtr)
 	fmt.Println("Serving", *dirPtr, "on port", *portPtr)
diff --git a/scouting/webserver/requests/BUILD b/scouting/webserver/requests/BUILD
index 795d0ee..a7593e7 100644
--- a/scouting/webserver/requests/BUILD
+++ b/scouting/webserver/requests/BUILD
@@ -21,6 +21,8 @@
         "//scouting/webserver/requests/messages:request_all_notes_response_go_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_go_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_response_go_fbs",
+        "//scouting/webserver/requests/messages:request_pit_images_go_fbs",
+        "//scouting/webserver/requests/messages:request_pit_images_response_go_fbs",
         "//scouting/webserver/requests/messages:request_shift_schedule_go_fbs",
         "//scouting/webserver/requests/messages:request_shift_schedule_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_actions_go_fbs",
@@ -29,6 +31,8 @@
         "//scouting/webserver/requests/messages:submit_driver_ranking_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_response_go_fbs",
+        "//scouting/webserver/requests/messages:submit_pit_image_go_fbs",
+        "//scouting/webserver/requests/messages:submit_pit_image_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_shift_schedule_go_fbs",
         "//scouting/webserver/requests/messages:submit_shift_schedule_response_go_fbs",
         "//scouting/webserver/server",
@@ -54,11 +58,14 @@
         "//scouting/webserver/requests/messages:request_all_notes_go_fbs",
         "//scouting/webserver/requests/messages:request_all_notes_response_go_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_go_fbs",
+        "//scouting/webserver/requests/messages:request_pit_images_go_fbs",
+        "//scouting/webserver/requests/messages:request_pit_images_response_go_fbs",
         "//scouting/webserver/requests/messages:request_shift_schedule_go_fbs",
         "//scouting/webserver/requests/messages:request_shift_schedule_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_actions_go_fbs",
         "//scouting/webserver/requests/messages:submit_driver_ranking_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_go_fbs",
+        "//scouting/webserver/requests/messages:submit_pit_image_go_fbs",
         "//scouting/webserver/requests/messages:submit_shift_schedule_go_fbs",
         "//scouting/webserver/server",
         "@com_github_google_flatbuffers//go:go_default_library",
diff --git a/scouting/webserver/requests/debug/BUILD b/scouting/webserver/requests/debug/BUILD
index ef14e5a..11311d2 100644
--- a/scouting/webserver/requests/debug/BUILD
+++ b/scouting/webserver/requests/debug/BUILD
@@ -14,10 +14,12 @@
         "//scouting/webserver/requests/messages:request_all_matches_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_notes_response_go_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_response_go_fbs",
+        "//scouting/webserver/requests/messages:request_pit_images_response_go_fbs",
         "//scouting/webserver/requests/messages:request_shift_schedule_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_actions_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_driver_ranking_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_response_go_fbs",
+        "//scouting/webserver/requests/messages:submit_pit_image_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_shift_schedule_response_go_fbs",
         "@com_github_google_flatbuffers//go:go_default_library",
     ],
diff --git a/scouting/webserver/requests/debug/debug.go b/scouting/webserver/requests/debug/debug.go
index 4837cfe..3e5b4f9 100644
--- a/scouting/webserver/requests/debug/debug.go
+++ b/scouting/webserver/requests/debug/debug.go
@@ -16,10 +16,12 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_matches_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_notes_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_notes_for_team_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_pit_images_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_actions_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_pit_image_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule_response"
 	flatbuffers "github.com/google/flatbuffers/go"
 )
@@ -136,6 +138,12 @@
 		request_notes_for_team_response.GetRootAsRequestNotesForTeamResponse)
 }
 
+func RequestPitImages(server string, requestBytes []byte) (*request_pit_images_response.RequestPitImagesResponseT, error) {
+	return sendMessage[request_pit_images_response.RequestPitImagesResponseT](
+		server+"/requests/request/pit_images", requestBytes,
+		request_pit_images_response.GetRootAsRequestPitImagesResponse)
+}
+
 func RequestAllNotes(server string, requestBytes []byte) (*request_all_notes_response.RequestAllNotesResponseT, error) {
 	return sendMessage[request_all_notes_response.RequestAllNotesResponseT](
 		server+"/requests/request/all_notes", requestBytes,
@@ -166,6 +174,12 @@
 		submit_actions_response.GetRootAsSubmitActionsResponse)
 }
 
+func SubmitPitImage(server string, requestBytes []byte) (*submit_pit_image_response.SubmitPitImageResponseT, error) {
+	return sendMessage[submit_pit_image_response.SubmitPitImageResponseT](
+		server+"/requests/submit/submit_pit_image", requestBytes,
+		submit_pit_image_response.GetRootAsSubmitPitImageResponse)
+}
+
 func Delete2023DataScouting(server string, requestBytes []byte) (*delete_2023_data_scouting_response.Delete2023DataScoutingResponseT, error) {
 	return sendMessage[delete_2023_data_scouting_response.Delete2023DataScoutingResponseT](
 		server+"/requests/delete/delete_2023_data_scouting", requestBytes,
diff --git a/scouting/webserver/requests/messages/BUILD b/scouting/webserver/requests/messages/BUILD
index db422ed..13fdc45 100644
--- a/scouting/webserver/requests/messages/BUILD
+++ b/scouting/webserver/requests/messages/BUILD
@@ -15,6 +15,10 @@
     "submit_notes_response",
     "request_notes_for_team",
     "request_notes_for_team_response",
+    "submit_pit_image",
+    "submit_pit_image_response",
+    "request_pit_images",
+    "request_pit_images_response",
     "request_shift_schedule",
     "request_shift_schedule_response",
     "submit_shift_schedule",
diff --git a/scouting/webserver/requests/messages/request_pit_images.fbs b/scouting/webserver/requests/messages/request_pit_images.fbs
new file mode 100644
index 0000000..b28f0a8
--- /dev/null
+++ b/scouting/webserver/requests/messages/request_pit_images.fbs
@@ -0,0 +1,7 @@
+namespace scouting.webserver.requests;
+
+table RequestPitImages {
+    team_number:string (id: 0);
+}
+
+root_type RequestPitImages;
diff --git a/scouting/webserver/requests/messages/request_pit_images_response.fbs b/scouting/webserver/requests/messages/request_pit_images_response.fbs
new file mode 100644
index 0000000..d9bb2cc
--- /dev/null
+++ b/scouting/webserver/requests/messages/request_pit_images_response.fbs
@@ -0,0 +1,13 @@
+namespace scouting.webserver.requests;
+
+table PitImage {
+	team_number:string (id:0);
+	check_sum:string (id:1);
+	image_path:string (id:2);
+}
+
+table RequestPitImagesResponse {
+	pit_image_list: [PitImage] (id:0);
+}
+
+root_type RequestPitImagesResponse;
diff --git a/scouting/webserver/requests/messages/submit_pit_image.fbs b/scouting/webserver/requests/messages/submit_pit_image.fbs
new file mode 100644
index 0000000..ffbaca2
--- /dev/null
+++ b/scouting/webserver/requests/messages/submit_pit_image.fbs
@@ -0,0 +1,9 @@
+namespace scouting.webserver.requests;
+
+table SubmitPitImage {
+	team_number:string (id:0);
+	image_path:string (id:1);
+	image_data:[ubyte] (id:2);
+}
+
+root_type SubmitPitImage;
diff --git a/scouting/webserver/requests/messages/submit_pit_image_response.fbs b/scouting/webserver/requests/messages/submit_pit_image_response.fbs
new file mode 100644
index 0000000..0b9d907
--- /dev/null
+++ b/scouting/webserver/requests/messages/submit_pit_image_response.fbs
@@ -0,0 +1,7 @@
+namespace scouting.webserver.requests;
+
+table SubmitPitImageResponse {
+    // empty response
+}
+
+root_type SubmitPitImageResponse;
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
index 533959c..51612ff 100644
--- a/scouting/webserver/requests/requests.go
+++ b/scouting/webserver/requests/requests.go
@@ -25,6 +25,8 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_notes_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_notes_for_team"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_notes_for_team_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_pit_images"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_pit_images_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_actions"
@@ -33,6 +35,8 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_pit_image"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_pit_image_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
@@ -49,6 +53,10 @@
 type Request2023DataScoutingResponseT = request_2023_data_scouting_response.Request2023DataScoutingResponseT
 type SubmitNotes = submit_notes.SubmitNotes
 type SubmitNotesResponseT = submit_notes_response.SubmitNotesResponseT
+type SubmitPitImage = submit_pit_image.SubmitPitImage
+type SubmitPitImageResponseT = submit_pit_image_response.SubmitPitImageResponseT
+type RequestPitImages = request_pit_images.RequestPitImages
+type RequestPitImagesResponseT = request_pit_images_response.RequestPitImagesResponseT
 type RequestNotesForTeam = request_notes_for_team.RequestNotesForTeam
 type RequestNotesForTeamResponseT = request_notes_for_team_response.RequestNotesForTeamResponseT
 type RequestShiftSchedule = request_shift_schedule.RequestShiftSchedule
@@ -77,7 +85,9 @@
 	ReturnStats2023ForTeam(teamNumber string, matchNumber int32, setNumber int32, compLevel string, preScouting bool) ([]db.Stats2023, error)
 	QueryAllShifts(int) ([]db.Shift, error)
 	QueryNotes(int32) ([]string, error)
+	QueryPitImages(string) ([]db.RequestedPitImage, error)
 	AddNotes(db.NotesData) error
+	AddPitImage(db.PitImage) error
 	AddDriverRanking(db.DriverRankingData) error
 	AddAction(db.Action) error
 	DeleteFromStats(string, int32, int32, string) error
@@ -378,6 +388,39 @@
 	w.Write(builder.FinishedBytes())
 }
 
+type submitPitImageScoutingHandler struct {
+	db Database
+}
+
+func (handler submitPitImageScoutingHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	requestBytes, err := io.ReadAll(req.Body)
+	if err != nil {
+		respondWithError(w, http.StatusBadRequest, fmt.Sprint("Failed to read request bytes:", err))
+		return
+	}
+
+	request, success := parseRequest(w, requestBytes, "SubmitPitImage", submit_pit_image.GetRootAsSubmitPitImage)
+	if !success {
+		return
+	}
+
+	err = handler.db.AddPitImage(db.PitImage{
+		TeamNumber: string(request.TeamNumber()),
+		CheckSum:   db.ComputeSha256FromByteArray(request.ImageDataBytes()),
+		ImagePath:  string(request.ImagePath()),
+		ImageData:  request.ImageDataBytes(),
+	})
+	if err != nil {
+		respondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to insert notes: %v", err))
+		return
+	}
+
+	var response SubmitPitImageResponseT
+	builder := flatbuffers.NewBuilder(10)
+	builder.Finish((&response).Pack(builder))
+	w.Write(builder.FinishedBytes())
+}
+
 func ConvertActionsToStat(submitActions *submit_actions.SubmitActions) (db.Stats2023, error) {
 	overall_time := int64(0)
 	cycles := int64(0)
@@ -576,6 +619,42 @@
 	w.Write(builder.FinishedBytes())
 }
 
+type requestPitImagesHandler struct {
+	db Database
+}
+
+func (handler requestPitImagesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	requestBytes, err := io.ReadAll(req.Body)
+	if err != nil {
+		respondWithError(w, http.StatusBadRequest, fmt.Sprint("Failed to read request bytes:", err))
+		return
+	}
+
+	request, success := parseRequest(w, requestBytes, "RequestPitImages", request_pit_images.GetRootAsRequestPitImages)
+	if !success {
+		return
+	}
+
+	images, err := handler.db.QueryPitImages(string(request.TeamNumber()))
+	if err != nil {
+		respondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query pit images: %v", err))
+		return
+	}
+
+	var response RequestPitImagesResponseT
+	for _, data := range images {
+		response.PitImageList = append(response.PitImageList, &request_pit_images_response.PitImageT{
+			TeamNumber: data.TeamNumber,
+			ImagePath:  data.ImagePath,
+			CheckSum:   data.CheckSum,
+		})
+	}
+
+	builder := flatbuffers.NewBuilder(1024)
+	builder.Finish((&response).Pack(builder))
+	w.Write(builder.FinishedBytes())
+}
+
 type requestNotesForTeamHandler struct {
 	db Database
 }
@@ -928,6 +1007,8 @@
 	scoutingServer.Handle("/requests/request/all_driver_rankings", requestAllDriverRankingsHandler{db})
 	scoutingServer.Handle("/requests/request/2023_data_scouting", request2023DataScoutingHandler{db})
 	scoutingServer.Handle("/requests/submit/submit_notes", submitNoteScoutingHandler{db})
+	scoutingServer.Handle("/requests/submit/submit_pit_image", submitPitImageScoutingHandler{db})
+	scoutingServer.Handle("/requests/request/pit_images", requestPitImagesHandler{db})
 	scoutingServer.Handle("/requests/request/notes_for_team", requestNotesForTeamHandler{db})
 	scoutingServer.Handle("/requests/submit/shift_schedule", submitShiftScheduleHandler{db})
 	scoutingServer.Handle("/requests/request/shift_schedule", requestShiftScheduleHandler{db})
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index 20a63ca..e50583b 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -17,11 +17,14 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_notes"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_notes_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_notes_for_team"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_pit_images"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_pit_images_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_actions"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_pit_image"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
 	flatbuffers "github.com/google/flatbuffers/go"
@@ -514,6 +517,86 @@
 	}
 }
 
+func TestSubmitPitImage(t *testing.T) {
+	database := MockDatabase{}
+	scoutingServer := server.NewScoutingServer()
+	HandleRequests(&database, scoutingServer)
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	builder := flatbuffers.NewBuilder(1024)
+	builder.Finish((&submit_pit_image.SubmitPitImageT{
+		TeamNumber: "483A", ImagePath: "483Arobot.jpg",
+		ImageData: []byte{12, 43, 54, 34, 98},
+	}).Pack(builder))
+
+	_, err := debug.SubmitPitImage("http://localhost:8080", builder.FinishedBytes())
+	if err != nil {
+		t.Fatal("Failed to submit pit image: ", err)
+	}
+
+	expected := []db.PitImage{
+		{
+			TeamNumber: "483A", CheckSum: "177d9dc52bc25f391232e82521259c378964c068832a9178d73448ba4ac5e0b1",
+			ImagePath: "483Arobot.jpg", ImageData: []byte{12, 43, 54, 34, 98},
+		},
+	}
+
+	if !reflect.DeepEqual(database.images, expected) {
+		t.Fatal("Submitted image did not match", expected, database.images)
+	}
+}
+
+func TestRequestPitImages(t *testing.T) {
+	db := MockDatabase{
+		images: []db.PitImage{
+			{
+				TeamNumber: "932", ImagePath: "pitimage.jpg",
+				ImageData: []byte{3, 34, 44, 65}, CheckSum: "abcdf",
+			},
+			{
+				TeamNumber: "234", ImagePath: "234robot.png",
+				ImageData: []byte{64, 54, 21, 21, 76, 32}, CheckSum: "egrfd",
+			},
+			{
+				TeamNumber: "93A", ImagePath: "abcd.jpg",
+				ImageData: []byte{92, 94, 10, 30, 57, 32, 32}, CheckSum: "rgegfd",
+			},
+		},
+	}
+
+	scoutingServer := server.NewScoutingServer()
+	HandleRequests(&db, scoutingServer)
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	builder := flatbuffers.NewBuilder(1024)
+	builder.Finish((&request_pit_images.RequestPitImagesT{"932"}).Pack(builder))
+
+	response, err := debug.RequestPitImages("http://localhost:8080", builder.FinishedBytes())
+	if err != nil {
+		t.Fatal("Failed to request pit images: ", err)
+	}
+
+	expected := request_pit_images_response.RequestPitImagesResponseT{
+		PitImageList: []*request_pit_images_response.PitImageT{
+			{
+				TeamNumber: "932", ImagePath: "pitimage.jpg", CheckSum: "abcdf",
+			},
+		},
+	}
+
+	if len(expected.PitImageList) != len(response.PitImageList) {
+		t.Fatal("Expected ", expected, ", but got ", *response)
+	}
+
+	for i, pit_image := range expected.PitImageList {
+		if !reflect.DeepEqual(*pit_image, *response.PitImageList[i]) {
+			t.Fatal("Expected for pit image", i, ":", *pit_image, ", but got:", *response.PitImageList[i])
+		}
+	}
+}
+
 func TestRequestShiftSchedule(t *testing.T) {
 	db := MockDatabase{
 		shiftSchedule: []db.Shift{
@@ -1024,6 +1107,7 @@
 	driver_ranking []db.DriverRankingData
 	stats2023      []db.Stats2023
 	actions        []db.Action
+	images         []db.PitImage
 }
 
 func (database *MockDatabase) AddToMatch(match db.TeamMatch) error {
@@ -1085,6 +1169,20 @@
 	return []db.Shift{}, nil
 }
 
+func (database *MockDatabase) QueryPitImages(requestedTeam string) ([]db.RequestedPitImage, error) {
+	var results []db.RequestedPitImage
+	for _, data := range database.images {
+		if data.TeamNumber == requestedTeam {
+			results = append(results, db.RequestedPitImage{
+				TeamNumber: data.TeamNumber,
+				ImagePath:  data.ImagePath,
+				CheckSum:   data.CheckSum,
+			})
+		}
+	}
+	return results, nil
+}
+
 func (database *MockDatabase) AddDriverRanking(data db.DriverRankingData) error {
 	database.driver_ranking = append(database.driver_ranking, data)
 	return nil
@@ -1099,6 +1197,11 @@
 	return nil
 }
 
+func (database *MockDatabase) AddPitImage(pitImage db.PitImage) error {
+	database.images = append(database.images, pitImage)
+	return nil
+}
+
 func (database *MockDatabase) ReturnActions() ([]db.Action, error) {
 	return database.actions, nil
 }
diff --git a/scouting/webserver/static/BUILD b/scouting/webserver/static/BUILD
index 0cf22f3..4b40c76 100644
--- a/scouting/webserver/static/BUILD
+++ b/scouting/webserver/static/BUILD
@@ -6,7 +6,10 @@
     importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/static",
     target_compatible_with = ["@platforms//cpu:x86_64"],
     visibility = ["//visibility:public"],
-    deps = ["//scouting/webserver/server"],
+    deps = [
+        "//scouting/db",
+        "//scouting/webserver/server",
+    ],
 )
 
 go_test(
@@ -19,5 +22,8 @@
     ],
     embed = [":static"],
     target_compatible_with = ["@platforms//cpu:x86_64"],
-    deps = ["//scouting/webserver/server"],
+    deps = [
+        "//scouting/db",
+        "//scouting/webserver/server",
+    ],
 )
diff --git a/scouting/webserver/static/static.go b/scouting/webserver/static/static.go
index fbad476..8e2e400 100644
--- a/scouting/webserver/static/static.go
+++ b/scouting/webserver/static/static.go
@@ -7,12 +7,14 @@
 	"fmt"
 	"io"
 	"log"
+	"mime"
 	"net/http"
 	"os"
 	"path/filepath"
 	"strings"
 	"time"
 
+	"github.com/frc971/971-Robot-Code/scouting/db"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
 )
 
@@ -28,6 +30,10 @@
 	"X-Accel-Expires": "0",
 }
 
+type ScoutingDatabase interface {
+	QueryPitImageByChecksum(checksum string) (db.PitImage, error)
+}
+
 func MaybeNoCache(h http.Handler) http.Handler {
 	fn := func(w http.ResponseWriter, r *http.Request) {
 		// We force the browser not to cache index.html so that
@@ -98,7 +104,7 @@
 	return shaSums
 }
 
-func HandleShaUrl(directory string, h http.Handler) http.Handler {
+func HandleShaUrl(directory string, h http.Handler, scoutingDatabase ScoutingDatabase) http.Handler {
 	shaSums := findAllFileShas(directory)
 
 	fn := func(w http.ResponseWriter, r *http.Request) {
@@ -132,9 +138,20 @@
 			// We found a file with this checksum. Serve that file.
 			r.URL.Path = path
 		} else {
-			// No file with this checksum found.
-			w.WriteHeader(http.StatusNotFound)
-			return
+			pitImage, err := scoutingDatabase.QueryPitImageByChecksum(hash)
+			if err == nil {
+				if parts[3] != pitImage.ImagePath {
+					log.Println("Got ", parts[3], "expected", pitImage.ImagePath)
+					w.WriteHeader(http.StatusBadRequest)
+					return
+				}
+				w.Header().Add("Content-Type", mime.TypeByExtension(pitImage.ImagePath))
+				w.Write(pitImage.ImageData)
+				return
+			} else { // No file with this checksum found.
+				w.WriteHeader(http.StatusNotFound)
+				return
+			}
 		}
 
 		h.ServeHTTP(w, r)
@@ -143,11 +160,10 @@
 	return http.HandlerFunc(fn)
 }
 
-// Serve pages in the specified directory.
-func ServePages(scoutingServer server.ScoutingServer, directory string) {
+func ServePages(scoutingServer server.ScoutingServer, directory string, scoutingDatabase ScoutingDatabase) {
 	// Serve the / endpoint given a folder of pages.
 	scoutingServer.Handle("/", MaybeNoCache(http.FileServer(http.Dir(directory))))
 
 	// Also serve files in a checksum-addressable manner.
-	scoutingServer.Handle("/sha256/", HandleShaUrl(directory, http.FileServer(http.Dir(directory))))
+	scoutingServer.Handle("/sha256/", HandleShaUrl(directory, http.FileServer(http.Dir(directory)), scoutingDatabase))
 }
diff --git a/scouting/webserver/static/static_test.go b/scouting/webserver/static/static_test.go
index 1d036a0..a68d973 100644
--- a/scouting/webserver/static/static_test.go
+++ b/scouting/webserver/static/static_test.go
@@ -1,14 +1,30 @@
 package static
 
 import (
+	"errors"
 	"fmt"
 	"io/ioutil"
 	"net/http"
 	"testing"
 
+	"github.com/frc971/971-Robot-Code/scouting/db"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
 )
 
+type MockDatabase struct {
+	images []db.PitImage
+}
+
+func (database *MockDatabase) QueryPitImageByChecksum(checksum string) (db.PitImage, error) {
+	for _, data := range database.images {
+		if data.CheckSum == checksum {
+			return data, nil
+		}
+	}
+
+	return db.PitImage{}, errors.New("Could not find pit image")
+}
+
 func expectEqual(t *testing.T, actual string, expected string) {
 	if actual != expected {
 		t.Error("Expected ", actual, " to equal ", expected)
@@ -16,6 +32,15 @@
 }
 
 func TestServing(t *testing.T) {
+	database := MockDatabase{
+		images: []db.PitImage{
+			{
+				TeamNumber: "971", CheckSum: "3yi32rhewd23",
+				ImagePath: "abc.png", ImageData: []byte("hello"),
+			},
+		},
+	}
+
 	cases := []struct {
 		// The path to request from the server.
 		path string
@@ -26,10 +51,11 @@
 		{"/", "<h1>This is the index</h1>\n"},
 		{"/root.txt", "Hello, this is the root page!"},
 		{"/page.txt", "Hello from a page!"},
+		{"/sha256/3yi32rhewd23/abc.png", "hello"},
 	}
 
 	scoutingServer := server.NewScoutingServer()
-	ServePages(scoutingServer, "test_pages")
+	ServePages(scoutingServer, "test_pages", &database)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -43,8 +69,9 @@
 // Makes sure that requesting / sets the proper headers so it doesn't get
 // cached.
 func TestDisallowedCache(t *testing.T) {
+	database := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	ServePages(scoutingServer, "test_pages")
+	ServePages(scoutingServer, "test_pages", &database)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -61,8 +88,9 @@
 // Makes sure that requesting anything other than / doesn't set the "do not
 // cache" headers.
 func TestAllowedCache(t *testing.T) {
+	database := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	ServePages(scoutingServer, "test_pages")
+	ServePages(scoutingServer, "test_pages", &database)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -77,11 +105,23 @@
 }
 
 func TestSha256(t *testing.T) {
+	database := MockDatabase{
+		images: []db.PitImage{
+			{
+				TeamNumber: "971", CheckSum: "3yi32rhewd23",
+				ImagePath: "abc.png", ImageData: []byte{32, 54, 23, 00},
+			},
+		},
+	}
 	scoutingServer := server.NewScoutingServer()
-	ServePages(scoutingServer, "test_pages")
+	ServePages(scoutingServer, "test_pages", &database)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
+	//Validate receiving the correct byte sequence from image request.
+	byteDataReceived := getData("sha256/3yi32rhewd23/abc.png", t)
+	expectEqual(t, string(byteDataReceived), string([]byte{32, 54, 23, 00}))
+
 	// Validate a valid checksum.
 	dataReceived := getData("sha256/553b9b29647a112136986cf93c57b988d1f12dc43d3b774f14a24e58d272dbff/root.txt", t)
 	expectEqual(t, dataReceived, "Hello, this is the root page!")
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index 6ecad54..55e9a8f 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -19,6 +19,7 @@
         "//scouting/www/entry",
         "//scouting/www/match_list",
         "//scouting/www/notes",
+        "//scouting/www/pit_scouting",
         "//scouting/www/shift_schedule",
         "//scouting/www/view",
     ],
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index 7b200d0..bf393e4 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -9,6 +9,7 @@
 import {ShiftScheduleModule} from '../shift_schedule';
 import {ViewModule} from '../view';
 import {DriverRankingModule} from '../driver_ranking';
+import {PitScoutingModule} from '../pit_scouting';
 
 @NgModule({
   declarations: [App],
@@ -21,6 +22,7 @@
     ShiftScheduleModule,
     DriverRankingModule,
     ViewModule,
+    PitScoutingModule,
   ],
   exports: [App],
   bootstrap: [App],
diff --git a/scouting/www/app/app.ng.html b/scouting/www/app/app.ng.html
index b7f1873..5f7ea9f 100644
--- a/scouting/www/app/app.ng.html
+++ b/scouting/www/app/app.ng.html
@@ -64,6 +64,15 @@
       View
     </a>
   </li>
+  <li class="nav-item">
+    <a
+      class="nav-link"
+      [class.active]="tabIs('Pit')"
+      (click)="switchTabToGuarded('Pit')"
+    >
+      Pit
+    </a>
+  </li>
 </ul>
 
 <ng-container [ngSwitch]="tab">
@@ -83,4 +92,5 @@
   <app-driver-ranking *ngSwitchCase="'DriverRanking'"></app-driver-ranking>
   <shift-schedule *ngSwitchCase="'ShiftSchedule'"></shift-schedule>
   <app-view *ngSwitchCase="'View'"></app-view>
+  <app-pit-scouting *ngSwitchCase="'Pit'"></app-pit-scouting>
 </ng-container>
diff --git a/scouting/www/app/app.ts b/scouting/www/app/app.ts
index c19895a..16e3197 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -6,7 +6,8 @@
   | 'Entry'
   | 'DriverRanking'
   | 'ShiftSchedule'
-  | 'View';
+  | 'View'
+  | 'Pit';
 
 // Ignore the guard for tabs that don't require the user to enter any data.
 const unguardedTabs: Tab[] = ['MatchList'];
diff --git a/scouting/www/pit_scouting/BUILD b/scouting/www/pit_scouting/BUILD
new file mode 100644
index 0000000..740dee1
--- /dev/null
+++ b/scouting/www/pit_scouting/BUILD
@@ -0,0 +1,18 @@
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
+
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
+    name = "pit_scouting",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
+    ],
+    deps = [
+        ":node_modules/@angular/forms",
+        "//scouting/webserver/requests/messages:error_response_ts_fbs",
+        "//scouting/webserver/requests/messages:submit_pit_image_response_ts_fbs",
+        "//scouting/webserver/requests/messages:submit_pit_image_ts_fbs",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+    ],
+)
diff --git a/scouting/www/pit_scouting/package.json b/scouting/www/pit_scouting/package.json
new file mode 100644
index 0000000..e58fc51
--- /dev/null
+++ b/scouting/www/pit_scouting/package.json
@@ -0,0 +1,7 @@
+{
+	"name": "@org_frc971/scouting/www/pit_scouting",
+	"private": true,
+	"dependencies": {
+			"@angular/forms": "15.1.5"
+	}
+}
diff --git a/scouting/www/pit_scouting/pit_scouting.component.css b/scouting/www/pit_scouting/pit_scouting.component.css
new file mode 100644
index 0000000..b224d73
--- /dev/null
+++ b/scouting/www/pit_scouting/pit_scouting.component.css
@@ -0,0 +1,8 @@
+* {
+  padding: 10px;
+}
+
+button {
+  touch-action: manipulation;
+  margin-top: 1vh;
+}
diff --git a/scouting/www/pit_scouting/pit_scouting.component.ts b/scouting/www/pit_scouting/pit_scouting.component.ts
new file mode 100644
index 0000000..b74c119
--- /dev/null
+++ b/scouting/www/pit_scouting/pit_scouting.component.ts
@@ -0,0 +1,75 @@
+import {Component} from '@angular/core';
+import {Builder, ByteBuffer} from 'flatbuffers';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {SubmitPitImage} from '../../webserver/requests/messages/submit_pit_image_generated';
+
+type Section = 'TeamSelection' | 'Data';
+
+interface Input {
+  teamNumber: number;
+  pitImage: HTMLImageElement;
+}
+
+@Component({
+  selector: 'app-pit-scouting',
+  templateUrl: './pit_scouting.ng.html',
+  styleUrls: ['../app/common.css', './pit_scouting.component.css'],
+})
+export class PitScoutingComponent {
+  section: Section = 'Data';
+
+  errorMessage = '';
+  teamNumber: number = 971;
+  pitImage: string = '';
+
+  async readFile(file): Promise<ArrayBuffer> {
+    return new Promise((resolve, reject) => {
+      let reader = new FileReader();
+      reader.addEventListener('loadend', (e) =>
+        resolve(e.target.result as ArrayBuffer)
+      );
+      reader.addEventListener('error', reject);
+      reader.readAsArrayBuffer(file);
+    });
+  }
+
+  async getImageData() {
+    const selectedFile = (<HTMLInputElement>document.getElementById('pitImage'))
+      .files[0];
+    return new Uint8Array(await this.readFile(selectedFile));
+  }
+
+  async submitData() {
+    const builder = new Builder();
+    const teamNumber = builder.createString(this.teamNumber.toString());
+    const pitImage = builder.createString(this.pitImage.toString());
+    const imageData = SubmitPitImage.createImageDataVector(
+      builder,
+      await this.getImageData()
+    );
+    builder.finish(
+      SubmitPitImage.createSubmitPitImage(
+        builder,
+        teamNumber,
+        pitImage,
+        imageData
+      )
+    );
+
+    const buffer = builder.asUint8Array();
+    const res = await fetch('/requests/submit/submit_pit_image', {
+      method: 'POST',
+      body: buffer,
+    });
+    if (!res.ok) {
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+
+      const errorMessage = parsedResponse.errorMessage();
+      this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
+    }
+    this.section = 'TeamSelection';
+    this.pitImage = '';
+  }
+}
diff --git a/scouting/www/pit_scouting/pit_scouting.module.ts b/scouting/www/pit_scouting/pit_scouting.module.ts
new file mode 100644
index 0000000..7d92f6c
--- /dev/null
+++ b/scouting/www/pit_scouting/pit_scouting.module.ts
@@ -0,0 +1,11 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {FormsModule} from '@angular/forms';
+import {PitScoutingComponent} from './pit_scouting.component';
+
+@NgModule({
+  declarations: [PitScoutingComponent],
+  exports: [PitScoutingComponent],
+  imports: [CommonModule, FormsModule],
+})
+export class PitScoutingModule {}
diff --git a/scouting/www/pit_scouting/pit_scouting.ng.html b/scouting/www/pit_scouting/pit_scouting.ng.html
new file mode 100644
index 0000000..98aa313
--- /dev/null
+++ b/scouting/www/pit_scouting/pit_scouting.ng.html
@@ -0,0 +1,30 @@
+<div class="header">
+  <h2>Pit Scouting</h2>
+</div>
+<ng-container [ngSwitch]="section">
+  <div *ngSwitchCase="'Data'" id="pitImageSelection" class="container-fluid">
+    <label for="teamNumber">Team Number</label>
+    <input
+      [(ngModel)]="teamNumber"
+      type="string"
+      id="teamNumber"
+      min="1"
+      max="9999"
+    />
+    <form action="setPitImage()">
+      <label for="pitImage">Select pit image:</label>
+      <br />
+      <input
+        id="pitImage"
+        [(ngModel)]="pitImage"
+        type="file"
+        accept="image/*"
+      />
+    </form>
+    <button id="submit-button" class="btn btn-success" (click)="submitData()">
+      Submit
+    </button>
+  </div>
+
+  <span class="error_message" role="alert">{{ errorMessage }}</span>
+</ng-container>