diff --git a/api/apipatrols.go b/api/apipatrols.go new file mode 100644 index 0000000..8633efc --- /dev/null +++ b/api/apipatrols.go @@ -0,0 +1,105 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" + "time" +) + +// ---------------------------------------------- +// 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 { + Name string `json:"name"` + } `json:"leader"` + 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"` +} + +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(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 { + 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/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) + } +} diff --git a/cmd/patrols.go b/cmd/patrols.go new file mode 100644 index 0000000..1a07c03 --- /dev/null +++ b/cmd/patrols.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/doneill/er-cli/api" + "github.com/doneill/er-cli/config" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" +) + +var days int + +// ---------------------------------------------- +// patrols command +// ---------------------------------------------- + +var patrolsCmd = &cobra.Command{ + Use: "patrols", + Short: "Get patrols data", + Long: `Return patrol data including serial number, state, ID, location, and time information`, + 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(days) + 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 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" + startTime := "N/A" + endTime := "N/A" + + if len(patrol.PatrolSegments) > 0 { + segment := patrol.PatrolSegments[0] + + if segment.Leader != nil { + l := segment.Leader + leader = l.Name + } + + if segment.StartLocation != nil { + location = fmt.Sprintf("%.6f, %.6f", + segment.StartLocation.Latitude, + segment.StartLocation.Longitude) + } + + startTime = formatTime(segment.TimeRange.StartTime) + endTime = formatTime(segment.TimeRange.EndTime) + } + + title := "N/A" + if patrol.Title != nil { + title = *patrol.Title + } + + return []string{ + 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{ + "Serial", + "State", + "ID", + "Title", + "Leader", + "Start Location", + "Start Time", + "End Time", + }) + table.SetBorders(tablewriter.Border{ + Left: true, + Top: true, + Right: true, + Bottom: true, + }) + table.SetCenterSeparator("|") + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + return table +} + +// ---------------------------------------------- +// initialize +// ---------------------------------------------- + +func init() { + rootCmd.AddCommand(patrolsCmd) + patrolsCmd.Flags().IntVarP(&days, "days", "d", 0, "Number of days to fetch patrols for (defaults to all)") +}