Skip to content

Commit 74321e7

Browse files
authored
Merge pull request #28 from github/zrdaley/assignment_grades
Add assignment grades CSV download command
2 parents 7514659 + b379e50 commit 74321e7

File tree

6 files changed

+334
-3
lines changed

6 files changed

+334
-3
lines changed

.github/workflows/golangci-lint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
# working-directory: somedir
2828

2929
# Optional: golangci-lint command line arguments.
30-
# args: --issues-exit-code=0
30+
args: --timeout=5m
3131

3232
# Optional: show only new issues if it's a pull request. The default value is `false`.
3333
# only-new-issues: true
@@ -40,4 +40,4 @@ jobs:
4040
# skip-pkg-cache: true
4141

4242
# Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
43-
# skip-build-cache: true
43+
# skip-build-cache: true
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package grades
2+
3+
import (
4+
"encoding/csv"
5+
"fmt"
6+
"log"
7+
"os"
8+
9+
"github.com/cli/cli/v2/pkg/cmdutil"
10+
"github.com/cli/go-gh"
11+
"github.com/cli/go-gh/pkg/browser"
12+
"github.com/cli/go-gh/pkg/term"
13+
"github.com/github/gh-classroom/cmd/gh-classroom/shared"
14+
"github.com/github/gh-classroom/pkg/classroom"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
func NewCmdAssignmentGrades(f *cmdutil.Factory) *cobra.Command {
19+
var (
20+
web bool
21+
assignmentID int
22+
filename string
23+
isGroupAssignment bool
24+
)
25+
26+
cmd := &cobra.Command{
27+
Use: "assignment-grades",
28+
Example: `$ gh classroom assignment-grades -a 4876`,
29+
Short: "Download a CSV of grades for an assignment in a classroom",
30+
Run: func(cmd *cobra.Command, args []string) {
31+
term := term.FromEnv()
32+
33+
client, err := gh.RESTClient(nil)
34+
var assignment classroom.Assignment
35+
if err != nil {
36+
log.Fatal(err)
37+
}
38+
39+
if assignmentID == 0 {
40+
classroom, err := shared.PromptForClassroom(client)
41+
classroomID := classroom.Id
42+
if err != nil {
43+
log.Fatal(err)
44+
}
45+
46+
assignment, err = shared.PromptForAssignment(client, classroomID)
47+
assignmentID = assignment.Id
48+
if err != nil {
49+
log.Fatal(err)
50+
}
51+
}
52+
53+
if web {
54+
if term.IsTerminalOutput() {
55+
fmt.Fprintln(cmd.ErrOrStderr(), "Opening in your browser.")
56+
}
57+
browser := browser.New("", cmd.OutOrStdout(), cmd.OutOrStderr())
58+
err := browser.Browse(assignment.Url())
59+
if err != nil {
60+
log.Fatal(err)
61+
}
62+
return
63+
}
64+
65+
response, err := classroom.GetAssignmentGrades(client, assignmentID)
66+
if err != nil {
67+
log.Fatal(err)
68+
}
69+
70+
if len(response) == 0 {
71+
log.Fatal("No grades were returned for assignment")
72+
}
73+
74+
f, err := os.Create(filename)
75+
if err != nil {
76+
log.Fatalln("failed to open file", err)
77+
}
78+
defer f.Close()
79+
80+
w := csv.NewWriter(f)
81+
defer w.Flush()
82+
83+
for i, grade := range response {
84+
if len(grade.GroupName) != 0 {
85+
isGroupAssignment = true
86+
}
87+
88+
if i == 0 {
89+
err := w.Write(gradeCSVHeaders(isGroupAssignment))
90+
if err != nil {
91+
log.Fatalln("error writing header to file", err)
92+
}
93+
}
94+
95+
row := []string{
96+
grade.AssignmentName,
97+
grade.AssignmentURL,
98+
grade.StarterCodeURL,
99+
grade.GithubUsername,
100+
grade.RosterIdentifier,
101+
grade.StudentRepositoryName,
102+
grade.StudentRepositoryURL,
103+
grade.SubmissionTimestamp,
104+
grade.PointsAwarded,
105+
grade.PointsAvailable,
106+
}
107+
if isGroupAssignment {
108+
row = append(row, grade.GroupName)
109+
}
110+
111+
err := w.Write(row)
112+
if err != nil {
113+
log.Fatalln("error writing row to file", err)
114+
}
115+
}
116+
fmt.Println("Successfully wrote grades to", filename)
117+
},
118+
}
119+
120+
cmd.Flags().BoolVar(&web, "web", false, "Open specified assignment in a web browser")
121+
cmd.Flags().IntVarP(&assignmentID, "assignment-id", "a", 0, "Assignment ID (optional)")
122+
cmd.Flags().StringVarP(&filename, "file-name", "f", "grades.csv", "File name (optional)")
123+
return cmd
124+
}
125+
126+
func gradeCSVHeaders(isGroupAssignment bool) []string {
127+
headers := []string{
128+
"assignment_name",
129+
"assignment_url",
130+
"starter_code_url",
131+
"github_username",
132+
"roster_identifier",
133+
"student_repository_name",
134+
"student_repository_url",
135+
"submission_timestamp",
136+
"points_awarded",
137+
"points_available",
138+
}
139+
if isGroupAssignment {
140+
headers = append(headers, "group_name")
141+
}
142+
return headers
143+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package grades
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/cli/cli/v2/pkg/cmdutil"
12+
"github.com/stretchr/testify/assert"
13+
"gopkg.in/h2non/gock.v1"
14+
)
15+
16+
func TestAssignmentGradesFatalOnInvalidAPIResponse(t *testing.T) {
17+
// Run the crashing code when FLAG is set
18+
if os.Getenv("FLAG") == "1" {
19+
defer gock.Off()
20+
t.Setenv("GITHUB_TOKEN", "999")
21+
22+
gock.New("https://api.github.com").
23+
Get("/assignments/1234/grades").
24+
Reply(200).
25+
JSON(`{ }`)
26+
27+
actual := new(bytes.Buffer)
28+
29+
f := &cmdutil.Factory{}
30+
command := NewCmdAssignmentGrades(f)
31+
command.SetOut(actual)
32+
command.SetErr(actual)
33+
command.SetArgs([]string{
34+
"-a1234",
35+
})
36+
37+
command.Execute() //nolint:errcheck
38+
return
39+
}
40+
41+
// Runs the test above in a subprocess
42+
cmd := exec.Command(os.Args[0], "-test.run=TestAssignmentGradesFatalOnInvalidAPIResponse")
43+
cmd.Env = append(os.Environ(), "FLAG=1")
44+
err := cmd.Run()
45+
46+
// Gets a fatal error
47+
e, ok := err.(*exec.ExitError)
48+
expectedErrorString := "exit status 1"
49+
assert.Equal(t, true, ok)
50+
assert.Equal(t, expectedErrorString, e.Error())
51+
}
52+
53+
func TestGettingGradesIndividualAssignment(t *testing.T) {
54+
t.Run("writes a csv when grades are returned from API", func(t *testing.T) {
55+
defer gock.Off()
56+
t.Setenv("GITHUB_TOKEN", "999")
57+
58+
// given an api response with grades returned
59+
gock.New("https://api.github.com").
60+
Get("/assignments/1234/grades").
61+
Reply(200).
62+
JSON(`[{
63+
"assignment_name": "assignment",
64+
"assignment_url": "assignment.url",
65+
"github_username": "username",
66+
"points_available": "100",
67+
"points_awarded": "97",
68+
"roster_identifier": "[email protected]",
69+
"starter_code_url":"startercode.url",
70+
"student_repository_name": "repo",
71+
"student_repository_url": "repo.url",
72+
"submission_timestamp": "MM-DD-YYYY"
73+
}]`)
74+
75+
actual := new(bytes.Buffer)
76+
outputFile := filepath.Join(t.TempDir(), "grades.csv")
77+
f := &cmdutil.Factory{}
78+
command := NewCmdAssignmentGrades(f)
79+
command.SetOut(actual)
80+
command.SetErr(actual)
81+
command.SetArgs([]string{
82+
"-a1234",
83+
"-f" + outputFile,
84+
})
85+
86+
// When the command is executed
87+
err := command.Execute()
88+
89+
// There should:
90+
// - be no error
91+
// - be a CSV written to the file passed in
92+
assert.NoError(t, err, "Should not error")
93+
94+
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
95+
t.Errorf("Expected persisted file at %s, did not find it: %s", outputFile, err)
96+
}
97+
b, err := os.ReadFile(outputFile)
98+
if err != nil {
99+
fmt.Print(err)
100+
}
101+
102+
expected :=
103+
"assignment_name,assignment_url,starter_code_url,github_username,roster_identifier,student_repository_name,student_repository_url,submission_timestamp,points_awarded,points_available\n" +
104+
"assignment,assignment.url,startercode.url,username,[email protected],repo,repo.url,MM-DD-YYYY,97,100\n"
105+
assert.Equal(t, string(b), expected)
106+
})
107+
}
108+
109+
func TestGettingGradesGroupAssignment(t *testing.T) {
110+
t.Run("writes a csv when grades are returned from API", func(t *testing.T) {
111+
defer gock.Off()
112+
t.Setenv("GITHUB_TOKEN", "999")
113+
114+
// given an api response with grades returned
115+
gock.New("https://api.github.com").
116+
Get("/assignments/1234/grades").
117+
Reply(200).
118+
JSON(`[{
119+
"assignment_name": "assignment",
120+
"assignment_url": "assignment.url",
121+
"github_username": "username",
122+
"points_available": "100",
123+
"points_awarded": "97",
124+
"roster_identifier": "[email protected]",
125+
"starter_code_url":"startercode.url",
126+
"student_repository_name": "repo",
127+
"student_repository_url": "repo.url",
128+
"submission_timestamp": "MM-DD-YYYY",
129+
"group_name": "group"
130+
}]`)
131+
132+
actual := new(bytes.Buffer)
133+
outputFile := filepath.Join(t.TempDir(), "grades.csv")
134+
f := &cmdutil.Factory{}
135+
command := NewCmdAssignmentGrades(f)
136+
command.SetOut(actual)
137+
command.SetErr(actual)
138+
command.SetArgs([]string{
139+
"-a1234",
140+
"-f" + outputFile,
141+
})
142+
143+
// When the command is executed
144+
err := command.Execute()
145+
146+
// There should:
147+
// - be no error
148+
// - be a CSV written to the file passed in
149+
assert.NoError(t, err, "Should not error")
150+
151+
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
152+
t.Errorf("Expected persisted file at %s, did not find it: %s", outputFile, err)
153+
}
154+
b, err := os.ReadFile(outputFile)
155+
if err != nil {
156+
fmt.Print(err)
157+
}
158+
159+
expected :=
160+
"assignment_name,assignment_url,starter_code_url,github_username,roster_identifier,student_repository_name,student_repository_url,submission_timestamp,points_awarded,points_available,group_name\n" +
161+
"assignment,assignment.url,startercode.url,username,[email protected],repo,repo.url,MM-DD-YYYY,97,100,group\n"
162+
assert.Equal(t, string(b), expected)
163+
})
164+
}

cmd/gh-classroom/root/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
acceptedAssignments "github.com/github/gh-classroom/cmd/gh-classroom/accepted-assignments"
88
"github.com/github/gh-classroom/cmd/gh-classroom/assignment"
9+
assignmentgrades "github.com/github/gh-classroom/cmd/gh-classroom/assignment-grades"
910
"github.com/github/gh-classroom/cmd/gh-classroom/assignments"
1011
"github.com/github/gh-classroom/cmd/gh-classroom/clone"
1112
"github.com/github/gh-classroom/cmd/gh-classroom/list"
@@ -24,6 +25,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command {
2425
cmd.AddCommand(assignment.NewCmdAssignment(f))
2526
cmd.AddCommand(acceptedAssignments.NewCmdAcceptedAssignments(f))
2627
cmd.AddCommand(clone.NewCmdClone(f))
28+
cmd.AddCommand(assignmentgrades.NewCmdAssignmentGrades(f))
2729

2830
return cmd
2931
}

pkg/classroom/classroom.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ type Assignment struct {
3333
StarterCodeRepository GithubRepository `json:"starter_code_repository"`
3434
}
3535

36+
type AssignmentGrade struct {
37+
AssignmentName string `json:"assignment_name"`
38+
AssignmentURL string `json:"assignment_url"`
39+
StarterCodeURL string `json:"starter_code_url"`
40+
GithubUsername string `json:"github_username"`
41+
RosterIdentifier string `json:"roster_identifier"`
42+
StudentRepositoryName string `json:"student_repository_name"`
43+
StudentRepositoryURL string `json:"student_repository_url"`
44+
SubmissionTimestamp string `json:"submission_timestamp"`
45+
PointsAwarded string `json:"points_awarded"`
46+
PointsAvailable string `json:"points_available"`
47+
GroupName string `json:"group_name"`
48+
}
49+
3650
type Classroom struct {
3751
Id int `json:"id"`
3852
Name string `json:"name"`

pkg/classroom/http.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func ListAssignments(client api.RESTClient, classroomID int, page int, perPage i
1515

1616
if len(response) == 0 {
1717
return AssignmentList{}, nil
18-
}
18+
}
1919

2020
assignmentList := NewAssignmentList(response)
2121

@@ -55,7 +55,15 @@ func GetAssignment(client api.RESTClient, assignmentID int) (Assignment, error)
5555
if err != nil {
5656
return Assignment{}, err
5757
}
58+
return response, nil
59+
}
5860

61+
func GetAssignmentGrades(client api.RESTClient, assignmentID int) ([]AssignmentGrade, error) {
62+
var response []AssignmentGrade
63+
err := client.Get(fmt.Sprintf("assignments/%v/grades", assignmentID), &response)
64+
if err != nil {
65+
return nil, err
66+
}
5967
return response, nil
6068
}
6169

0 commit comments

Comments
 (0)