From 8e81ba190b825253f3d5dbfdcee9b4481d947d3d Mon Sep 17 00:00:00 2001 From: Matas Lauzadis Date: Tue, 19 Oct 2021 22:44:53 -0500 Subject: [PATCH 1/3] Batch update decisions --- .../docs/reference/services/Decision.md | 85 +++++++++++++++++++ gateway/services/decision.go | 10 +++ services/decision/controller/controller.go | 68 +++++++++++++++ services/decision/models/decisions.go | 5 ++ 4 files changed, 168 insertions(+) create mode 100644 services/decision/models/decisions.go diff --git a/documentation/docs/reference/services/Decision.md b/documentation/docs/reference/services/Decision.md index b541bae9..63202c5f 100644 --- a/documentation/docs/reference/services/Decision.md +++ b/documentation/docs/reference/services/Decision.md @@ -101,6 +101,91 @@ Response format: } ``` +POST /decision/batch/ +-------------------------- + +Updates the decision for each of the users as specified in the `id` field of each of the decisions. The list of updated decisions is returned in the response. + +**Batched version of the above.** + +Request format: +``` +{ + "decisions": [ + { + "id": "github9279532", + "status": "ACCEPTED", + "wave": 1 + }, + { + "id": "github1234567", + "status": "ACCEPTED", + "wave": 1 + } + ] +} +``` + +Response format: +``` +{ + "decisions": [ + { + "finalized": false, + "id": "github9279532", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862, + "history": [ + { + "finalized": false, + "id": "github9279532", + "status": "PENDING", + "wave": 0, + "reviewer": "github9279532", + "timestamp": 1526673845 + }, + { + "finalized": true, + "id": "github9279532", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862 + } + ] + }, + { + "finalized": false, + "id": "github1234567", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862, + "history": [ + { + "finalized": false, + "id": "github1234567", + "status": "PENDING", + "wave": 0, + "reviewer": "github9279532", + "timestamp": 1526673845 + }, + { + "finalized": true, + "id": "github1234567", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862 + } + ] + } + ] +} +``` + POST /decision/finalize/ -------------------------- diff --git a/gateway/services/decision.go b/gateway/services/decision.go index b7db46f7..2537c41a 100644 --- a/gateway/services/decision.go +++ b/gateway/services/decision.go @@ -25,6 +25,12 @@ var DecisionRoutes = arbor.RouteCollection{ "/decision/", alice.New(middleware.AuthMiddleware([]models.Role{models.AdminRole, models.StaffRole}), middleware.IdentificationMiddleware).ThenFunc(UpdateDecision).ServeHTTP, }, + arbor.Route{ + "UpdateDecisionBatch", + "POST", + "/decision/batch/", + alice.New(middleware.AuthMiddleware([]models.Role{models.AdminRole, models.StaffRole}), middleware.IdentificationMiddleware).ThenFunc(UpdateDecisionBatch).ServeHTTP, + }, arbor.Route{ "GetFilteredDecisions", "GET", @@ -57,6 +63,10 @@ func UpdateDecision(w http.ResponseWriter, r *http.Request) { arbor.POST(w, config.DECISION_SERVICE+r.URL.String(), DecisionFormat, "", r) } +func UpdateDecisionBatch(w http.ResponseWriter, r *http.Request) { + arbor.POST(w, config.DECISION_SERVICE+r.URL.String(), DecisionFormat, "", r) +} + func GetFilteredDecisions(w http.ResponseWriter, r *http.Request) { arbor.GET(w, config.DECISION_SERVICE+r.URL.String(), DecisionFormat, "", r) } diff --git a/services/decision/controller/controller.go b/services/decision/controller/controller.go index c624654e..92519933 100644 --- a/services/decision/controller/controller.go +++ b/services/decision/controller/controller.go @@ -20,6 +20,7 @@ func SetupController(route *mux.Route) { router.HandleFunc("/", UpdateDecision).Methods("POST") router.HandleFunc("/finalize/", FinalizeDecision).Methods("POST") router.HandleFunc("/filter/", GetFilteredDecisions).Methods("GET") + router.HandleFunc("/batch/", UpdateDecisionBatch).Methods("POST") router.HandleFunc("/{id}/", GetDecision).Methods("GET") router.HandleFunc("/internal/stats/", GetStats).Methods("GET") @@ -112,6 +113,73 @@ func UpdateDecision(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(updated_decision) } +/* + Endpoint to update the decisions for specified users. + If any of the existing decisions are already finalized, an error is reported. +*/ +func UpdateDecisionBatch(w http.ResponseWriter, r *http.Request) { + var decisions models.Decisions + json.NewDecoder(r.Body).Decode(&decisions) + + var updated_decisions models.FilteredDecisions + + // Check if any of the decisions have been finalized already. If so, fail early. + for _, decision := range decisions.Decisions { + if decision.ID == "" { + errors.WriteError(w, r, errors.MalformedRequestError("Must provide id parameter in request.", "Must provide id parameter in request.")) + return + } + + has_decision, err := service.HasDecision(decision.ID) + + if err != nil { + errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not determine user's decision.")) + return + } + + if has_decision { + existing_decision_history, err := service.GetDecision(decision.ID) + + if err != nil { + errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get current user's existing decision history.")) + return + } + + if existing_decision_history.Finalized { + errors.WriteError(w, r, errors.AttributeMismatchError("Cannot modify finalized decision for user ID "+decision.ID, "Cannot modify finalized decision for user ID "+decision.ID)) + return + } + } + } + + for _, decision := range decisions.Decisions { + + decision.Reviewer = r.Header.Get("HackIllinois-Identity") + decision.Timestamp = time.Now().Unix() + decision.ExpiresAt = decision.Timestamp + utils.HoursToUnixSeconds(config.DECISION_EXPIRATION_HOURS) + // Finalized is always false, unless explicitly set to true via the appropriate endpoint. + decision.Finalized = false + + err := service.UpdateDecision(decision.ID, decision) + + if err != nil { + errors.WriteError(w, r, errors.InternalError(err.Error(), "Could not update decision.")) + return + } + + updated_decision, err := service.GetDecision(decision.ID) + + if err != nil { + errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not fetch updated decision.")) + return + } + + updated_decisions.Decisions = append(updated_decisions.Decisions, *updated_decision) + } + + json.NewEncoder(w).Encode(updated_decisions) +} + /* Finalizes / unfinalizes the decision associated with the provided ID. Finalized decisions are blocked from further review, unless unfinalized. diff --git a/services/decision/models/decisions.go b/services/decision/models/decisions.go new file mode 100644 index 00000000..77137d8f --- /dev/null +++ b/services/decision/models/decisions.go @@ -0,0 +1,5 @@ +package models + +type Decisions struct { + Decisions []Decision `json:"decisions"` +} From 79b5f619ed3dd9a2444676d666b0f75089e7efb4 Mon Sep 17 00:00:00 2001 From: Matas Lauzadis Date: Tue, 19 Oct 2021 23:14:24 -0500 Subject: [PATCH 2/3] Batch finalize decisions --- gateway/services/decision.go | 10 +++ services/decision/controller/controller.go | 89 +++++++++++++++++++ .../decision/models/decisions_finalized.go | 5 ++ 3 files changed, 104 insertions(+) create mode 100644 services/decision/models/decisions_finalized.go diff --git a/gateway/services/decision.go b/gateway/services/decision.go index 2537c41a..93031c17 100644 --- a/gateway/services/decision.go +++ b/gateway/services/decision.go @@ -43,6 +43,12 @@ var DecisionRoutes = arbor.RouteCollection{ "/decision/finalize/", alice.New(middleware.AuthMiddleware([]models.Role{models.AdminRole}), middleware.IdentificationMiddleware).ThenFunc(FinalizeDecision).ServeHTTP, }, + arbor.Route{ + "FinalizeDecisionBatch", + "POST", + "/decision/finalize/batch/", + alice.New(middleware.AuthMiddleware([]models.Role{models.AdminRole}), middleware.IdentificationMiddleware).ThenFunc(FinalizeDecision).ServeHTTP, + }, arbor.Route{ "GetDecision", "GET", @@ -74,3 +80,7 @@ func GetFilteredDecisions(w http.ResponseWriter, r *http.Request) { func FinalizeDecision(w http.ResponseWriter, r *http.Request) { arbor.POST(w, config.DECISION_SERVICE+r.URL.String(), DecisionFormat, "", r) } + +func FinalizeDecisionBatch(w http.ResponseWriter, r *http.Request) { + arbor.POST(w, config.DECISION_SERVICE+r.URL.String(), DecisionFormat, "", r) +} diff --git a/services/decision/controller/controller.go b/services/decision/controller/controller.go index 92519933..b1a5fc2c 100644 --- a/services/decision/controller/controller.go +++ b/services/decision/controller/controller.go @@ -19,6 +19,7 @@ func SetupController(route *mux.Route) { router.HandleFunc("/", GetCurrentDecision).Methods("GET") router.HandleFunc("/", UpdateDecision).Methods("POST") router.HandleFunc("/finalize/", FinalizeDecision).Methods("POST") + router.HandleFunc("/finalize/batch/", FinalizeDecisionBatch).Methods("POST") router.HandleFunc("/filter/", GetFilteredDecisions).Methods("GET") router.HandleFunc("/batch/", UpdateDecisionBatch).Methods("POST") router.HandleFunc("/{id}/", GetDecision).Methods("GET") @@ -198,6 +199,11 @@ func FinalizeDecision(w http.ResponseWriter, r *http.Request) { // Assuming we are working on the specified user's decision existing_decision_history, err := service.GetDecision(id) + if err != nil { + errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get current user's existing decision history.")) + return + } + // It is an error to finalize a finalized decision, or unfinalize an unfinalized decision. if existing_decision_history.Finalized == decision_finalized.Finalized { errors.WriteError(w, r, errors.AttributeMismatchError("Superfluous request. Existing decision already at desired state of finalization.", "Superfluous request. Existing decision already at desired state of finalization.")) @@ -246,6 +252,89 @@ func FinalizeDecision(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(updated_decision) } +func FinalizeDecisionBatch(w http.ResponseWriter, r *http.Request) { + var decisions_finalized models.DecisionsFinalized // decisions which need to be finalized + json.NewDecoder(r.Body).Decode(&decisions_finalized) + + var decisions models.FilteredDecisions // decisions which have been finalized + + // Check if any of the IDs are missing, or if any of the requests are superfluous. If so, fail early. + for _, decision_finalized := range decisions_finalized.DecisionsFinalized { + if decision_finalized.ID == "" { + errors.WriteError(w, r, errors.MalformedRequestError("Must provide id parameter in request.", "Must provide id parameter in request.")) + return + } + + // Assuming we are working on the specified user's decision + existing_decision_history, err := service.GetDecision(decision_finalized.ID) + + if err != nil { + errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get current user's existing decision history.")) + return + } + + // It is an error to finalize a finalized decision, or unfinalize an unfinalized decision. + if existing_decision_history.Finalized == decision_finalized.Finalized { + errors.WriteError(w, r, errors.AttributeMismatchError("Superfluous request. Existing decision already at desired state of finalization.", "Superfluous request. Existing decision already at desired state of finalization.")) + return + } + } + + for _, decision_finalized := range decisions_finalized.DecisionsFinalized { + id := decision_finalized.ID + + existing_decision_history, err := service.GetDecision(id) + + if err != nil { + errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get current user's existing decision history.")) + return + } + + var latest_decision models.Decision + latest_decision.Finalized = decision_finalized.Finalized + latest_decision.ID = decision_finalized.ID + latest_decision.Status = existing_decision_history.Status + latest_decision.Wave = existing_decision_history.Wave + latest_decision.Reviewer = r.Header.Get("HackIllinois-Identity") + latest_decision.Timestamp = time.Now().Unix() + latest_decision.ExpiresAt = latest_decision.Timestamp + utils.HoursToUnixSeconds(config.DECISION_EXPIRATION_HOURS) + + err = service.UpdateDecision(id, latest_decision) + + if err != nil { + errors.WriteError(w, r, errors.InternalError(err.Error(), "Error updating the decision, in an attempt to alter its finalized status.")) + return + } + + updated_decision, err := service.GetDecision(id) + + if err != nil { + errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not fetch updated decision.")) + return + } + + if updated_decision.Finalized { + err = service.AddUserToMailList(id, updated_decision) + + if err != nil { + errors.WriteError(w, r, errors.InternalError(err.Error(), "Could not add user to mail list.")) + return + } + } else { + err = service.RemoveUserFromMailList(id, updated_decision) + + if err != nil { + errors.WriteError(w, r, errors.InternalError(err.Error(), "Could not remove user from mail list.")) + return + } + } + + decisions.Decisions = append(decisions.Decisions, *updated_decision) + } + + json.NewEncoder(w).Encode(decisions) +} + /* Endpoint to get decisions based on a filter */ diff --git a/services/decision/models/decisions_finalized.go b/services/decision/models/decisions_finalized.go new file mode 100644 index 00000000..1f387be6 --- /dev/null +++ b/services/decision/models/decisions_finalized.go @@ -0,0 +1,5 @@ +package models + +type DecisionsFinalized struct { + DecisionsFinalized []DecisionFinalized `json:"decisions"` +} From 1b3b98c3c2b193451eda415e0a99b1a8a2aed351 Mon Sep 17 00:00:00 2001 From: Matas Lauzadis Date: Tue, 19 Oct 2021 23:17:24 -0500 Subject: [PATCH 3/3] Add documentation for batch finalize --- .../docs/reference/services/Decision.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/documentation/docs/reference/services/Decision.md b/documentation/docs/reference/services/Decision.md index 63202c5f..e93f298e 100644 --- a/documentation/docs/reference/services/Decision.md +++ b/documentation/docs/reference/services/Decision.md @@ -229,6 +229,90 @@ Response format: } ``` + +POST /decision/finalize/batch/ +-------------------------- + +Finalizes / unfinalizes the decisions for the list of provided users. The full decision history is returned in the response. This endpoint will return an AttributeMismatchError if the requested action results in a Finalized status matching the current Finalized status. + +**Batched version of the above.** + +Request format: +``` +{ + "decisions:", [ + { + "id": "github9279532", + "finalized": true + }, + { + "id": "github1234567", + "finalized": true + } + ] +} +``` + +Response format: +``` +{ + "decisions": [ + { + "finalized": true, + "id": "github9279532", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862, + "history": [ + { + "finalized": false, + "id": "github9279532", + "status": "PENDING", + "wave": 0, + "reviewer": "github9279532", + "timestamp": 1526673845 + }, + { + "finalized": true, + "id": "github9279532", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862 + } + ] + }, + { + "finalized": true, + "id": "github1234567", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862, + "history": [ + { + "finalized": false, + "id": "github1234567", + "status": "PENDING", + "wave": 0, + "reviewer": "github9279532", + "timestamp": 1526673845 + }, + { + "finalized": true, + "id": "github1234567", + "status": "ACCEPTED", + "wave": 1, + "reviewer": "github9279532", + "timestamp": 1526673862 + } + ] + } + ] +} +``` + GET /decision/filter/?key=value ----------------------------------