diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9c9a59 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# aiVLE CLI + +Course administration utility for [aiVLE](https://github.com/edu-ai/aivle-web) (AI Virtual Learning Environment). + +## Getting started + +Before running the executable, you need to create a `.env` file in the same directory with the following content: +``` +API_ROOT=http://192.168.3.51:8000 +``` + +1. `API_ROOT`: root of aiVLE backend API, for example, `http://127.0.0.1:8000` or `https://aivle-api.leotan.cn/api/v1` + +## Features + +1. Download submissions +2. Download evaluation results as CSV file +3. Upload LumiNUS student roster Excel to aiVLE course whitelist +4. Get API token from username and password diff --git a/go.mod b/go.mod index e4d5d82..85b6e37 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,5 @@ go 1.14 require ( github.com/AlecAivazis/survey/v2 v2.3.2 github.com/joho/godotenv v1.4.0 + github.com/xuri/excelize/v2 v2.5.0 // indirect ) diff --git a/go.sum b/go.sum index b0ba62f..238d116 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= @@ -18,22 +19,49 @@ github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.3 h1:rD8TBkYWkObWO0oLDFCbwMeZ4KoalxQy+QgniCj3nKI= +github.com/richardlehane/mscfb v1.0.3/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1 h1:RfrALnSNXzmXLbGct/P2b4xkFz4e8Gmj/0Vj9M9xC1o= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1 h1:52QO5WkIUcHGIR7EnGagH88x1bUzqGXTC5/1bDTUQ7U= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/xuri/efp v0.0.0-20210322160811-ab561f5b45e3 h1:EpI0bqf/eX9SdZDwlMmahKM+CDBgNbsXMhsN28XrM8o= +github.com/xuri/efp v0.0.0-20210322160811-ab561f5b45e3/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.5.0 h1:nDDVfX0qaDuGjAvb+5zTd0Bxxoqa1Ffv9B4kiE23PTM= +github.com/xuri/excelize/v2 v2.5.0/go.mod h1:rSu0C3papjzxQA3sdK8cU544TebhrPUoTOaGPIh0Q1A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkprecIQKdPHtR9jCHF5nB8uzc= golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985 h1:4CSI6oo7cOjJKajidEljs9h+uP0rRZBPPPhcCbj5mw8= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 6e8e91f..2cee37d 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ const ( OperationPrintToken = "print token" OperationDownloadSubmissions = "download submissions" OperationDownloadResults = "download results" + OperationUploadWhitelist = "upload whitelist" ) // the questions to ask @@ -80,7 +81,7 @@ func main() { operation := "" err = survey.AskOne(&survey.Select{ Message: "Choose an operation:", - Options: []string{OperationExit, OperationPrintToken, OperationDownloadSubmissions, OperationDownloadResults}, + Options: []string{OperationExit, OperationDownloadSubmissions, OperationDownloadResults, OperationUploadWhitelist, OperationPrintToken}, Default: "print token", }, &operation) if err != nil { @@ -95,6 +96,8 @@ func main() { operations.DownloadSubmissions(apiRoot, tokenResponse.Token) } else if operation == OperationDownloadResults { operations.DownloadResults(apiRoot, tokenResponse.Token) + } else if operation == OperationUploadWhitelist { + operations.UploadWhitelist(apiRoot, tokenResponse.Token) } } } diff --git a/models/models.go b/models/models.go index e08cdfe..a6aa3f1 100644 --- a/models/models.go +++ b/models/models.go @@ -30,3 +30,17 @@ type Participation struct { User User `json:"user"` Role string `json:"role"` } + +type Course struct { + Id int `json:"id"` + Code string `json:"code"` + AcademicYear string `json:"academic_year"` + Semester int `json:"semester"` + Visible bool `json:"visible"` + Participation struct { + ParticipationId int `json:"id"` + Role string `json:"role"` + UserId int `json:"user"` + CourseId int `json:"course"` + } `json:"participation"` +} diff --git a/operations/download_results.go b/operations/download_results.go index bd3cbc6..17c8c5f 100644 --- a/operations/download_results.go +++ b/operations/download_results.go @@ -3,7 +3,6 @@ package operations import ( "encoding/csv" "fmt" - "github.com/AlecAivazis/survey/v2" "net/http" "os" "strconv" @@ -12,22 +11,11 @@ import ( func DownloadResults(apiRoot string, token string) { client := &http.Client{} - // get list of tasks - tasks, err := getTasks(client, apiRoot, token) - if err != nil { - panic(err) - } // select one of the tasks - var taskNames []string - for _, task := range tasks { - taskNames = append(taskNames, task.Name) - } - taskName := 0 - err = survey.AskOne(&survey.Select{Message: "Please select a task:", Options: taskNames}, &taskName) + selectedTask, err := selectOneTask(client, apiRoot, token) if err != nil { panic(err) } - selectedTask := tasks[taskName] // get submissions in the selected task submissions, err := getSubmissionsByTask(client, apiRoot, token, selectedTask.Id, true) if err != nil { diff --git a/operations/download_submissions.go b/operations/download_submissions.go index f2506c8..682df14 100644 --- a/operations/download_submissions.go +++ b/operations/download_submissions.go @@ -13,22 +13,11 @@ import ( func DownloadSubmissions(apiRoot string, token string) { client := &http.Client{} - // get list of tasks - tasks, err := getTasks(client, apiRoot, token) - if err != nil { - panic(err) - } // select one of the tasks - var taskNames []string - for _, task := range tasks { - taskNames = append(taskNames, task.Name) - } - taskName := 0 - err = survey.AskOne(&survey.Select{Message: "Please select a task:", Options: taskNames}, &taskName) + selectedTask, err := selectOneTask(client, apiRoot, token) if err != nil { panic(err) } - selectedTask := tasks[taskName] markedForGrading := true err = survey.AskOne(&survey.Confirm{Message: "Download marked-for-grading submissions only? (default Yes)", Default: true}, &markedForGrading) // get submissions in the selected task diff --git a/operations/prompt_utils.go b/operations/prompt_utils.go new file mode 100644 index 0000000..5e857b0 --- /dev/null +++ b/operations/prompt_utils.go @@ -0,0 +1,44 @@ +package operations + +import ( + "aivle-cli/models" + "fmt" + "github.com/AlecAivazis/survey/v2" + "net/http" +) + +func selectOneTask(client *http.Client, apiRoot string, token string) (selectedTask models.Task, err error) { + tasks, err := getTasks(client, apiRoot, token) + if err != nil { + return + } + var taskNames []string + for _, task := range tasks { + taskNames = append(taskNames, task.Name) + } + var taskIndex = 0 + err = survey.AskOne(&survey.Select{Message: "Please select a task:", Options: taskNames}, &taskIndex) + if err != nil { + panic(err) + } + selectedTask = tasks[taskIndex] + return +} + +func selectOneCourse(client *http.Client, apiRoot string, token string) (selectedCourse models.Course, err error) { + courses, err := getCourses(client, apiRoot, token) + if err != nil { + return + } + var courseNames []string + for _, course := range courses { + courseNames = append(courseNames, fmt.Sprintf("%s - %s Semester %d", course.Code, course.AcademicYear, course.Semester)) + } + var courseIndex = 0 + err = survey.AskOne(&survey.Select{Message: "Please select a course:", Options: courseNames}, &courseIndex) + if err != nil { + panic(err) + } + selectedCourse = courses[courseIndex] + return +} diff --git a/operations/upload_whitelist.go b/operations/upload_whitelist.go new file mode 100644 index 0000000..a0925ff --- /dev/null +++ b/operations/upload_whitelist.go @@ -0,0 +1,49 @@ +package operations + +import ( + "github.com/AlecAivazis/survey/v2" + "github.com/xuri/excelize/v2" + "net/http" +) + +func UploadWhitelist(apiRoot string, token string) { + client := &http.Client{} + // read the student list Excel file + var fileName string + err := survey.AskOne(&survey.Input{Message: "LumiNUS student list file location (.xlsx)"}, &fileName) + if err != nil { + panic(err) + } + f, err := excelize.OpenFile(fileName) + if err != nil { + panic(err) + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + rows, err := f.GetRows("Results") + if err != nil { + panic(err) + } + var emailList []string + for i, row := range rows { + if i <= 2 { + continue + } + emailList = append(emailList, row[1]) + } + // select one of the courses + selectedCourse, err := selectOneCourse(client, apiRoot, token) + if err != nil { + panic(err) + } + // upload the whitelist + for _, email := range emailList { + err = addWhitelist(client, apiRoot, token, selectedCourse.Id, email) + if err != nil { + panic(err) + } + } +} diff --git a/operations/utils.go b/operations/utils.go index b488d9c..2b8e10a 100644 --- a/operations/utils.go +++ b/operations/utils.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" + "strings" ) func apiGetRequest(apiRoot string, token string, api string) (*http.Request, error) { @@ -17,6 +19,16 @@ func apiGetRequest(apiRoot string, token string, api string) (*http.Request, err return req, nil } +func apiPostRequest(apiRoot string, token string, api string, formData *url.Values) (*http.Request, error) { + req, err := http.NewRequest("POST", apiRoot+api, strings.NewReader(formData.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Token "+token) + return req, nil +} + func getTasks(client *http.Client, apiRoot string, token string) (tasks []models.Task, err error) { req, err := apiGetRequest(apiRoot, token, "/api/v1/tasks/") if err != nil { @@ -26,6 +38,7 @@ func getTasks(client *http.Client, apiRoot string, token string) (tasks []models if err != nil { return } + defer resp.Body.Close() err = json.NewDecoder(resp.Body).Decode(&tasks) return } @@ -47,6 +60,7 @@ func getSubmissionsByTask(client *http.Client, apiRoot string, token string, tas if err != nil { return } + defer resp.Body.Close() err = json.NewDecoder(resp.Body).Decode(&submissions) return } @@ -60,6 +74,35 @@ func getParticipantsByCourse(client *http.Client, apiRoot string, token string, if err != nil { return } + defer resp.Body.Close() err = json.NewDecoder(resp.Body).Decode(&participants) return } + +func addWhitelist(client *http.Client, apiRoot string, token string, courseId int, email string) (err error) { + req, err := apiPostRequest(apiRoot, token, "/api/v1/whitelist/", + &url.Values{"course": {strconv.Itoa(courseId)}, "email": {email}}) + if err != nil { + return + } + resp, err := client.Do(req) + defer resp.Body.Close() + return +} + +func getCourses(client *http.Client, apiRoot string, token string) (courses []models.Course, err error) { + // TODO: support pagination + req, err := apiGetRequest(apiRoot, token, "/api/v1/courses/") + if err != nil { + panic(err) + } + resp, err := client.Do(req) + if err != nil { + panic(err) + } + var results struct { + Results []models.Course `json:"results"` + } + err = json.NewDecoder(resp.Body).Decode(&results) + return results.Results, err +}