From 4d3018e995f7806cbefb84a78de1b25cec56ab68 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Wed, 15 Jan 2025 17:47:12 -0800 Subject: [PATCH 1/4] initial patrols command --- api/apipatrols.go | 57 ++++++++++++++++++++++++++ api/ersvc.go | 4 ++ cmd/patrols.go | 100 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 api/apipatrols.go create mode 100644 cmd/patrols.go diff --git a/api/apipatrols.go b/api/apipatrols.go new file mode 100644 index 0000000..77099cb --- /dev/null +++ b/api/apipatrols.go @@ -0,0 +1,57 @@ +package api + +import ( + "fmt" +) + +// ---------------------------------------------- +// Patrol types +// ---------------------------------------------- + +type PatrolsResponse struct { + Data struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []Patrol `json:"results"` + } `json:"data"` + Status struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"status"` +} + +type Patrol struct { + ID string `json:"id"` + SerialNumber int `json:"serial_number"` + State string `json:"state"` + Title *string `json:"title"` + PatrolSegments []PatrolSegment `json:"patrol_segments"` +} + +type PatrolSegment struct { + Leader *struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + } `json:"leader"` + PatrolType string `json:"patrol_type"` +} + +// ---------------------------------------------- +// Client methods +// ---------------------------------------------- + +func (c *Client) Patrols() (*PatrolsResponse, error) { + req, err := c.newRequest("GET", API_PATROLS, false) + if err != nil { + return nil, fmt.Errorf("failed to create patrols request: %w", err) + } + + var response PatrolsResponse + if err := c.doRequest(req, &response); err != nil { + return nil, fmt.Errorf("failed to get patrols: %w", err) + } + + return &response, nil +} diff --git a/api/ersvc.go b/api/ersvc.go index 6001388..64e29ed 100644 --- a/api/ersvc.go +++ b/api/ersvc.go @@ -16,6 +16,10 @@ const API_V1 = "/api/v1.0" const API_AUTH = "/oauth2/token" +const API_ACTIVITY = API_V1 + "/activity" + +const API_PATROLS = API_ACTIVITY + "/patrols" + const API_SUBJECT = API_V1 + "/subject" const API_SUBJECTS = API_V1 + "/subjects" diff --git a/cmd/patrols.go b/cmd/patrols.go new file mode 100644 index 0000000..b2f2b9f --- /dev/null +++ b/cmd/patrols.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "fmt" + "log" + "os" + + "github.com/doneill/er-cli/api" + "github.com/doneill/er-cli/config" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" +) + +// ---------------------------------------------- +// patrols command +// ---------------------------------------------- + +var patrolsCmd = &cobra.Command{ + Use: "patrols", + Short: "Get patrols data", + Long: `Return patrol data including ID, serial number, state, title, and leader`, + Run: func(cmd *cobra.Command, args []string) { + patrols() + }, +} + +// ---------------------------------------------- +// functions +// ---------------------------------------------- + +func patrols() { + client := api.ERClient(config.Sitename(), config.Token()) + handlePatrols(client) +} + +func handlePatrols(client *api.Client) { + patrolsResponse, err := client.Patrols() + if err != nil { + log.Fatalf("Error getting patrols: %v", err) + } + + if patrolsResponse == nil || len(patrolsResponse.Data.Results) == 0 { + fmt.Println("No patrols found") + return + } + + table := configurePatrolsTable() + for _, patrol := range patrolsResponse.Data.Results { + table.Append(formatPatrolData(&patrol)) + } + table.Render() +} + +func formatPatrolData(patrol *api.Patrol) []string { + leader := "N/A" + if len(patrol.PatrolSegments) > 0 && patrol.PatrolSegments[0].Leader != nil { + l := patrol.PatrolSegments[0].Leader + leader = fmt.Sprintf("%s %s (%s)", l.FirstName, l.LastName, l.Username) + } + + title := "N/A" + if patrol.Title != nil { + title = *patrol.Title + } + + return []string{ + patrol.ID, + fmt.Sprintf("%d", patrol.SerialNumber), + patrol.State, + title, + leader, + } +} + +func configurePatrolsTable() *tablewriter.Table { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{ + "ID", + "Serial", + "State", + "Title", + "Leader", + }) + table.SetBorders(tablewriter.Border{ + Left: true, + Top: true, + Right: true, + Bottom: true, + }) + table.SetCenterSeparator("|") + return table +} + +// ---------------------------------------------- +// initialize +// ---------------------------------------------- + +func init() { + rootCmd.AddCommand(patrolsCmd) +} From 89f81a766c4a92493d711d1fdf486ed899a51493 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Wed, 15 Jan 2025 18:36:55 -0800 Subject: [PATCH 2/4] rorder columns, add location and start/end times, exclude empty segments --- api/apipatrols.go | 18 ++++++++++++++++-- cmd/patrols.go | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/api/apipatrols.go b/api/apipatrols.go index 77099cb..6d76b7d 100644 --- a/api/apipatrols.go +++ b/api/apipatrols.go @@ -35,7 +35,19 @@ type PatrolSegment struct { LastName string `json:"last_name"` Username string `json:"username"` } `json:"leader"` - PatrolType string `json:"patrol_type"` + PatrolType string `json:"patrol_type"` + StartLocation *Location `json:"start_location"` + TimeRange TimeRange `json:"time_range"` +} + +type Location struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +type TimeRange struct { + StartTime *string `json:"start_time"` + EndTime *string `json:"end_time"` } // ---------------------------------------------- @@ -43,7 +55,9 @@ type PatrolSegment struct { // ---------------------------------------------- func (c *Client) Patrols() (*PatrolsResponse, error) { - req, err := c.newRequest("GET", API_PATROLS, false) + endpoint := fmt.Sprintf("%s?exclude_empty_patrols=true", API_PATROLS) + + req, err := c.newRequest("GET", endpoint, false) if err != nil { return nil, fmt.Errorf("failed to create patrols request: %w", err) } diff --git a/cmd/patrols.go b/cmd/patrols.go index b2f2b9f..f9a14e1 100644 --- a/cmd/patrols.go +++ b/cmd/patrols.go @@ -18,7 +18,7 @@ import ( var patrolsCmd = &cobra.Command{ Use: "patrols", Short: "Get patrols data", - Long: `Return patrol data including ID, serial number, state, title, and leader`, + Long: `Return patrol data including serial number, state, ID, location, and time information`, Run: func(cmd *cobra.Command, args []string) { patrols() }, @@ -53,9 +53,30 @@ func handlePatrols(client *api.Client) { func formatPatrolData(patrol *api.Patrol) []string { leader := "N/A" - if len(patrol.PatrolSegments) > 0 && patrol.PatrolSegments[0].Leader != nil { - l := patrol.PatrolSegments[0].Leader - leader = fmt.Sprintf("%s %s (%s)", l.FirstName, l.LastName, l.Username) + location := "N/A" + startTime := "N/A" + endTime := "N/A" + + if len(patrol.PatrolSegments) > 0 { + segment := patrol.PatrolSegments[0] + + if segment.Leader != nil { + l := segment.Leader + leader = fmt.Sprintf("%s %s (%s)", l.FirstName, l.LastName, l.Username) + } + + if segment.StartLocation != nil { + location = fmt.Sprintf("%.6f, %.6f", + segment.StartLocation.Latitude, + segment.StartLocation.Longitude) + } + + if segment.TimeRange.StartTime != nil { + startTime = *segment.TimeRange.StartTime + } + if segment.TimeRange.EndTime != nil { + endTime = *segment.TimeRange.EndTime + } } title := "N/A" @@ -64,22 +85,28 @@ func formatPatrolData(patrol *api.Patrol) []string { } return []string{ - patrol.ID, fmt.Sprintf("%d", patrol.SerialNumber), patrol.State, + patrol.ID, title, leader, + location, + startTime, + endTime, } } func configurePatrolsTable() *tablewriter.Table { table := tablewriter.NewWriter(os.Stdout) table.SetHeader([]string{ - "ID", "Serial", "State", + "ID", "Title", "Leader", + "Start Location", + "Start Time", + "End Time", }) table.SetBorders(tablewriter.Border{ Left: true, @@ -88,6 +115,8 @@ func configurePatrolsTable() *tablewriter.Table { Bottom: true, }) table.SetCenterSeparator("|") + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) return table } From 11226468d7ba2c87cb1813655ff08a0dfed72ee0 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Wed, 15 Jan 2025 19:18:12 -0800 Subject: [PATCH 3/4] add date filter, user input how many days they want to fetch --- api/apipatrols.go | 44 +++++++++++++++++++++++++++++++++++++++----- cmd/patrols.go | 29 +++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/api/apipatrols.go b/api/apipatrols.go index 6d76b7d..8633efc 100644 --- a/api/apipatrols.go +++ b/api/apipatrols.go @@ -1,7 +1,10 @@ package api import ( + "encoding/json" "fmt" + "net/url" + "time" ) // ---------------------------------------------- @@ -31,9 +34,7 @@ type Patrol struct { type PatrolSegment struct { Leader *struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Username string `json:"username"` + Name string `json:"name"` } `json:"leader"` PatrolType string `json:"patrol_type"` StartLocation *Location `json:"start_location"` @@ -50,12 +51,45 @@ type TimeRange struct { EndTime *string `json:"end_time"` } +type DateRangeFilter struct { + DateRange struct { + Lower string `json:"lower"` + Upper string `json:"upper"` + } `json:"date_range"` + PatrolsOverlapDaterange bool `json:"patrols_overlap_daterange"` +} + // ---------------------------------------------- // Client methods // ---------------------------------------------- -func (c *Client) Patrols() (*PatrolsResponse, error) { - endpoint := fmt.Sprintf("%s?exclude_empty_patrols=true", API_PATROLS) +func (c *Client) Patrols(days int) (*PatrolsResponse, error) { + var endpoint string + + if days > 0 { + now := time.Now().UTC() + upper := now + lower := now.AddDate(0, 0, -days) + + filter := DateRangeFilter{ + PatrolsOverlapDaterange: false, + } + filter.DateRange.Lower = lower.Format("2006-01-02T15:04:05.000Z") + filter.DateRange.Upper = upper.Format("2006-01-02T15:04:05.000Z") + + filterJSON, err := json.Marshal(filter) + if err != nil { + return nil, fmt.Errorf("failed to marshal date filter: %w", err) + } + + params := url.Values{} + params.Add("filter", string(filterJSON)) + params.Add("exclude_empty_patrols", "true") + + endpoint = fmt.Sprintf("%s?%s", API_PATROLS, params.Encode()) + } else { + endpoint = fmt.Sprintf("%s?exclude_empty_patrols=true", API_PATROLS) + } req, err := c.newRequest("GET", endpoint, false) if err != nil { diff --git a/cmd/patrols.go b/cmd/patrols.go index f9a14e1..1a07c03 100644 --- a/cmd/patrols.go +++ b/cmd/patrols.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "time" "github.com/doneill/er-cli/api" "github.com/doneill/er-cli/config" @@ -11,6 +12,8 @@ import ( "github.com/spf13/cobra" ) +var days int + // ---------------------------------------------- // patrols command // ---------------------------------------------- @@ -34,7 +37,7 @@ func patrols() { } func handlePatrols(client *api.Client) { - patrolsResponse, err := client.Patrols() + patrolsResponse, err := client.Patrols(days) if err != nil { log.Fatalf("Error getting patrols: %v", err) } @@ -51,6 +54,19 @@ func handlePatrols(client *api.Client) { table.Render() } +func formatTime(timeStr *string) string { + if timeStr == nil { + return "N/A" + } + + t, err := time.Parse(time.RFC3339, *timeStr) + if err != nil { + return "Invalid Time" + } + + return t.Format("02 Jan 15:04") +} + func formatPatrolData(patrol *api.Patrol) []string { leader := "N/A" location := "N/A" @@ -62,7 +78,7 @@ func formatPatrolData(patrol *api.Patrol) []string { if segment.Leader != nil { l := segment.Leader - leader = fmt.Sprintf("%s %s (%s)", l.FirstName, l.LastName, l.Username) + leader = l.Name } if segment.StartLocation != nil { @@ -71,12 +87,8 @@ func formatPatrolData(patrol *api.Patrol) []string { segment.StartLocation.Longitude) } - if segment.TimeRange.StartTime != nil { - startTime = *segment.TimeRange.StartTime - } - if segment.TimeRange.EndTime != nil { - endTime = *segment.TimeRange.EndTime - } + startTime = formatTime(segment.TimeRange.StartTime) + endTime = formatTime(segment.TimeRange.EndTime) } title := "N/A" @@ -126,4 +138,5 @@ func configurePatrolsTable() *tablewriter.Table { func init() { rootCmd.AddCommand(patrolsCmd) + patrolsCmd.Flags().IntVarP(&days, "days", "d", 0, "Number of days to fetch patrols for (defaults to all)") } From f5995a5b336fb5a9f35c7db5ddacd485808b6e2e Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Wed, 15 Jan 2025 19:23:40 -0800 Subject: [PATCH 4/4] add initial patrol tests --- api/patrols_test.go | 210 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 api/patrols_test.go diff --git a/api/patrols_test.go b/api/patrols_test.go new file mode 100644 index 0000000..b66cda0 --- /dev/null +++ b/api/patrols_test.go @@ -0,0 +1,210 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestPatrols(t *testing.T) { + tests := []struct { + name string + days int + mockResponse string + expectedError bool + validateResult func(*testing.T, *PatrolsResponse) + }{ + { + name: "successful response without date filter", + days: 0, + mockResponse: `{ + "data": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": "test123", + "serial_number": 1001, + "state": "open", + "title": "Test Patrol", + "patrol_segments": [ + { + "leader": {"name": "John Doe"}, + "patrol_type": "boat_patrol", + "start_location": {"latitude": 1.234, "longitude": 5.678}, + "time_range": { + "start_time": "2025-01-15T10:00:00.000Z", + "end_time": "2025-01-15T11:00:00.000Z" + } + } + ] + } + ] + }, + "status": { + "code": 200, + "message": "OK" + } + }`, + expectedError: false, + validateResult: func(t *testing.T, response *PatrolsResponse) { + if response == nil { + t.Fatal("Expected non-nil response") + } + if len(response.Data.Results) != 1 { + t.Errorf("Expected 1 result, got %d", len(response.Data.Results)) + } + patrol := response.Data.Results[0] + if patrol.ID != "test123" { + t.Errorf("Expected ID 'test123', got '%s'", patrol.ID) + } + if patrol.SerialNumber != 1001 { + t.Errorf("Expected serial number 1001, got %d", patrol.SerialNumber) + } + if len(patrol.PatrolSegments) == 0 { + t.Fatal("Expected at least one patrol segment") + } + if patrol.PatrolSegments[0].Leader == nil { + t.Fatal("Expected non-nil leader") + } + if patrol.PatrolSegments[0].Leader.Name != "John Doe" { + t.Errorf("Expected leader name 'John Doe', got '%s'", patrol.PatrolSegments[0].Leader.Name) + } + }, + }, + { + name: "successful response with date filter", + days: 7, + mockResponse: `{ + "data": { + "count": 1, + "results": [ + { + "id": "test456", + "serial_number": 1002, + "state": "closed" + } + ] + }, + "status": { + "code": 200, + "message": "OK" + } + }`, + expectedError: false, + validateResult: func(t *testing.T, response *PatrolsResponse) { + if response == nil { + t.Fatal("Expected non-nil response") + } + if len(response.Data.Results) != 1 { + t.Errorf("Expected 1 result, got %d", len(response.Data.Results)) + } + }, + }, + { + name: "error response", + days: 0, + mockResponse: `{"status": {"code": 500, "message": "Internal Server Error"}}`, + expectedError: true, + validateResult: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate request + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + + if tt.days > 0 { + if !strings.Contains(r.URL.String(), "filter=") { + t.Error("Expected filter parameter in URL for date-filtered request") + } + if !strings.Contains(r.URL.String(), "patrols_overlap_daterange") { + t.Error("Expected patrols_overlap_daterange in filter") + } + } + + // Return mock response + w.Header().Set("Content-Type", "application/json") + if strings.Contains(tt.mockResponse, `"code": 500`) { + w.WriteHeader(http.StatusInternalServerError) + } + if _, err := w.Write([]byte(tt.mockResponse)); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + client := ERClient("test", "test-token", server.URL) + response, err := client.Patrols(tt.days) + + if tt.expectedError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if tt.validateResult != nil { + tt.validateResult(t, response) + } + }) + } +} + +func TestDateRangeFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + filterStr := r.URL.Query().Get("filter") + if filterStr == "" { + t.Error("Expected filter parameter in URL") + return + } + + var filter DateRangeFilter + err := json.Unmarshal([]byte(filterStr), &filter) + if err != nil { + t.Errorf("Failed to parse filter JSON: %v", err) + return + } + + // Validate date format + _, err = time.Parse(time.RFC3339, filter.DateRange.Lower) + if err != nil { + t.Errorf("Invalid lower date format: %v", err) + } + + _, err = time.Parse(time.RFC3339, filter.DateRange.Upper) + if err != nil { + t.Errorf("Invalid upper date format: %v", err) + } + + if filter.PatrolsOverlapDaterange { + t.Error("Expected PatrolsOverlapDaterange to be false") + } + + // Return a valid response + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"data":{"count":0,"results":[]},"status":{"code":200,"message":"OK"}}`)); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + client := ERClient("test", "test-token", server.URL) + _, err := client.Patrols(7) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +}