github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/cmd/approvelist/main.go (about) 1 package main 2 3 import ( 4 "context" 5 "flag" 6 "fmt" 7 "log" 8 "net/http" 9 "os" 10 "slices" 11 "strings" 12 "text/tabwriter" 13 "time" 14 15 "connectrpc.com/connect" 16 "github.com/360EntSecGroup-Skylar/excelize" 17 "github.com/quickfeed/quickfeed/internal/env" 18 "github.com/quickfeed/quickfeed/qf" 19 "github.com/quickfeed/quickfeed/qf/qfconnect" 20 "github.com/quickfeed/quickfeed/web/interceptor" 21 ) 22 23 const ( 24 srcSuffix = "-original.xlsx" 25 dstSuffix = "-approve-list.xlsx" 26 pass = "Godkjent" 27 fail = "Ikke godkjent" 28 ) 29 30 func NewQuickFeed(serverURL, token string) qfconnect.QuickFeedServiceClient { 31 return qfconnect.NewQuickFeedServiceClient( 32 http.DefaultClient, 33 serverURL, 34 connect.WithInterceptors( 35 interceptor.NewTokenAuthClientInterceptor(token), 36 ), 37 ) 38 } 39 40 func main() { 41 var ( 42 serverURL = flag.String("server", "https://uis.itest.run", "UiS' QuickFeed server URL") 43 passLimit = flag.Int("limit", 6, "number of assignments required to pass") 44 showAll = flag.Bool("all", false, "show all students") 45 courseCode = flag.String("course", "DAT320", "course code to query (case sensitive)") 46 year = flag.Int("year", time.Now().Year(), "year of course to fetch from QuickFeed") 47 ) 48 flag.Usage = func() { 49 fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options]\n", os.Args[0]) 50 flag.PrintDefaults() 51 fmt.Fprintln(flag.CommandLine.Output(), "\nTo use this tool, GITHUB_ACCESS_TOKEN must be set to the personal access token of a course teacher.") 52 } 53 flag.Parse() 54 55 as, err := loadApproveSheet(*courseCode) 56 if err != nil { 57 log.Fatal(err) 58 } 59 courseSubmissions, enrollments, err := getSubmissions(*serverURL, *courseCode, uint32(*year)) 60 if err != nil { 61 log.Fatal(err) 62 } 63 64 numPass := 0 65 buf := newOutput() 66 quickfeedStudents := make(map[string]string) // map of students found on quickfeed: student id -> student name 67 for _, enroll := range enrollments { 68 // ignore course admins and teachers 69 if enroll.IsAdmin() || enroll.IsTeacher() { 70 continue 71 } 72 studID := enroll.GetUser().GetStudentID() 73 student := enroll.Name() 74 if quickfeedStudents[studID] != "" { 75 fmt.Printf("Duplicate student ID: %s, %q and %q\n", studID, quickfeedStudents[studID], student) 76 } 77 quickfeedStudents[studID] = student 78 79 submissions := courseSubmissions.For(enroll.ID) 80 numApproved := numApproved(submissions) 81 approvedValue := fail 82 if approved(numApproved, *passLimit) { 83 approvedValue = pass 84 numPass++ 85 } 86 87 rowNum, err := as.lookupRow(studID) 88 if err != nil { 89 // student ID not found in FS database, but has approved assignments 90 rowNum, err = as.lookupRowByName(student) 91 if err != nil { 92 // student name not found in FS database, but has approved assignments 93 buf.addQF(student, studID, approvedValue, numApproved) 94 continue 95 } 96 } 97 as.setApproveCell(rowNum, approvedValue) 98 // use student name from FS 99 student = as.lookupStudentByRow(rowNum) 100 buf.addBoth(rowNum, student, studID, approvedValue, numApproved) 101 } 102 103 // find students signed up to course, but not found in QuickFeed 104 for studID, rowNum := range as.approveStudMap { 105 _, ok := quickfeedStudents[studID] 106 if !ok { 107 // student found in FS, but not in QuickFeed 108 buf.addFS(rowNum, as.lookupStudentByRow(rowNum), studID, fail, 0) 109 as.setApproveCell(rowNum, fail) 110 } 111 } 112 113 buf.Print(*showAll) 114 fmt.Printf("Total: %d, passed: %d, fail: %d\n", len(as.approveMap), numPass, len(as.approveMap)-numPass) 115 if err = saveApproveSheet(*courseCode, as.sheetName, as.approveMap); err != nil { 116 log.Fatal(err) 117 } 118 } 119 120 type output struct { 121 fs map[int]string // row -> student data 122 qf map[int]string 123 both map[int]string 124 negRow int 125 } 126 127 func newOutput() *output { 128 return &output{ 129 fs: make(map[int]string), 130 qf: make(map[int]string), 131 both: make(map[int]string), 132 negRow: -1, 133 } 134 } 135 136 func (o *output) addFS(row int, student, studID, approveValue string, numApproved int) { 137 o.fs[row] = out(row, student, studID, approveValue, numApproved, true, false) 138 } 139 140 func (o *output) addQF(student, studID, approveValue string, numApproved int) { 141 // we use a negative row number for students found in QuickFeed, but not in FS 142 o.qf[o.negRow] = outNoRow(student, studID, approveValue, numApproved, false, true) 143 o.negRow-- 144 } 145 146 func (o *output) addBoth(row int, student, studID, approveValue string, numApproved int) { 147 o.both[row] = out(row, student, studID, approveValue, numApproved, true, true) 148 } 149 150 func (o *output) Print(showAll bool) { 151 tw := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) 152 fmt.Fprint(tw, head()) 153 154 rows := Keys(o.both) 155 if showAll { 156 slices.Sort(rows) 157 for _, r := range rows { 158 fmt.Fprint(tw, o.both[r]) 159 } 160 } 161 rows = Keys(o.fs) 162 slices.Sort(rows) 163 for _, r := range rows { 164 fmt.Fprint(tw, o.fs[r]) 165 } 166 rows = Keys(o.qf) 167 slices.Sort(rows) 168 for _, r := range rows { 169 fmt.Fprint(tw, o.qf[r]) 170 } 171 tw.Flush() 172 fmt.Println("----------") 173 fmt.Printf("FS: %d, QF: %d, Both: %d\n", len(o.fs), len(o.qf), len(o.both)) 174 } 175 176 func numApproved(submissions []*qf.Submission) int { 177 numApproved := 0 178 duplicateAssignments := make(map[uint64]struct{}) 179 for _, s := range submissions { 180 // ignore duplicate approved assignments 181 if _, ok := duplicateAssignments[s.AssignmentID]; ok { 182 continue 183 } 184 if s.IsApproved() { 185 duplicateAssignments[s.AssignmentID] = struct{}{} 186 numApproved++ 187 } 188 } 189 return numApproved 190 } 191 192 func approved(numApproved, passLimit int) bool { 193 return numApproved >= passLimit 194 } 195 196 func head() string { 197 return "Row#\tStudent\tStudID\tApproved\tFS\tQF\t#Approved\n" 198 } 199 200 func out(row int, student, studID, approvedValue string, numApproved int, fs, qf bool) string { 201 return fmt.Sprintf("%d\t%s\t%s\t%s\t%s\t%s\t%d\n", row, student, studID, approvedValue, mark(fs), mark(qf), numApproved) 202 } 203 204 func outNoRow(student, studID, approvedValue string, numApproved int, fs, qf bool) string { 205 return fmt.Sprintf("\t%s\t%s\t%s\t%s\t%s\t%d\n", student, studID, approvedValue, mark(fs), mark(qf), numApproved) 206 } 207 208 func mark(b bool) string { 209 if b { 210 return "✓" 211 } 212 return "x" 213 } 214 215 func Keys[K comparable, V any](m map[K]V) []K { 216 ks := make([]K, 0, len(m)) 217 for k := range m { 218 ks = append(ks, k) 219 } 220 return ks 221 } 222 223 func getSubmissions(serverURL, courseCode string, year uint32) (*qf.CourseSubmissions, []*qf.Enrollment, error) { 224 token, err := env.GetAccessToken() 225 if err != nil { 226 return nil, nil, err 227 } 228 229 client := NewQuickFeed(serverURL, token) 230 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 231 defer cancel() 232 233 user, err := client.GetUser(ctx, connect.NewRequest(&qf.Void{})) 234 if err != nil { 235 return nil, nil, err 236 } 237 var courseID uint64 238 for _, enrollment := range user.Msg.GetEnrollments() { 239 course := enrollment.GetCourse() 240 if course.GetCode() == courseCode && course.GetYear() == year { 241 courseID = course.GetID() 242 break 243 } 244 } 245 if courseID == 0 { 246 return nil, nil, fmt.Errorf("course %s-%d not found", courseCode, year) 247 } 248 249 submissionCourseRequest := &qf.SubmissionRequest{ 250 CourseID: courseID, 251 FetchMode: &qf.SubmissionRequest_Type{ 252 Type: qf.SubmissionRequest_ALL, 253 }, 254 } 255 submissions, err := client.GetSubmissionsByCourse(ctx, connect.NewRequest(submissionCourseRequest)) 256 if err != nil { 257 return nil, nil, fmt.Errorf("failed to get submissions for course %s: %w", courseCode, err) 258 } 259 enrollments, err := client.GetEnrollments(ctx, connect.NewRequest(&qf.EnrollmentRequest{ 260 FetchMode: &qf.EnrollmentRequest_CourseID{ 261 CourseID: courseID, 262 }, 263 })) 264 if err != nil { 265 return nil, nil, fmt.Errorf("failed to get enrollments for course %s: %w", courseCode, err) 266 } 267 return submissions.Msg, enrollments.Msg.Enrollments, err 268 } 269 270 func partialMatch(name string, studentMap map[string]int) (int, error) { 271 nameParts := strings.Split(strings.ToLower(name), " ") 272 possibleNames := make(map[string][]string) 273 for expectedName := range studentMap { 274 expectedNameParts := strings.Split(strings.ToLower(expectedName), " ") 275 matchCount := 0 276 for _, n := range nameParts { 277 for _, m := range expectedNameParts { 278 if n == m { 279 matchCount++ 280 } 281 } 282 } 283 if matchCount > 1 { 284 // if at least two parts of the names match 285 possibleNames[name] = append(possibleNames[name], expectedName) 286 // fmt.Printf("Probable match found: %s = %s\n", name, expectedName) 287 } 288 } 289 switch { 290 case len(possibleNames[name]) == 0: 291 return 0, fmt.Errorf("not found: %s", name) 292 case len(possibleNames[name]) > 1: 293 return 0, fmt.Errorf("multiple possibilities found for: %s --> %v", name, possibleNames[name]) 294 } 295 return studentMap[possibleNames[name][0]], nil 296 } 297 298 func fileName(courseCode, suffix string) string { 299 return strings.ToLower(courseCode) + suffix 300 } 301 302 const ( 303 firstNameColumn = "Fornavn" 304 lastNameColumn = "Etternavn" 305 studentNumColumn = "Studentnr." 306 candidateNumColumn = "Kandidatnr." 307 approvedColumn = "Godkjenning" 308 ) 309 310 type approveSheet struct { 311 sheetName string 312 headerLabels map[string]string 313 headerIndexes map[string]int 314 rows [][]string 315 approveNameMap map[string]int 316 approveStudMap map[string]int 317 approveMap map[string]string 318 } 319 320 func newApproveSheet(sheetName string, rows [][]string) (*approveSheet, error) { 321 as := &approveSheet{ 322 sheetName: sheetName, 323 headerLabels: map[string]string{ 324 firstNameColumn: "A", 325 lastNameColumn: "B", 326 studentNumColumn: "C", 327 candidateNumColumn: "D", 328 approvedColumn: "E", 329 }, 330 headerIndexes: map[string]int{ 331 firstNameColumn: 0, 332 lastNameColumn: 1, 333 studentNumColumn: 2, 334 candidateNumColumn: 3, 335 approvedColumn: 4, 336 }, 337 rows: rows[1:], // skip header row 338 approveNameMap: make(map[string]int), // map of full names to row numbers 339 approveStudMap: make(map[string]int), // map of student numbers to row numbers 340 approveMap: make(map[string]string), // map of approve cells to approval status 341 } 342 for i, row := range as.rows { // skip header row 343 rowNum := i + 2 // since we skip the header row 344 fn := as.fullName(row) 345 sn := as.studentNum(row) 346 as.approveNameMap[fn] = rowNum 347 as.approveStudMap[sn] = rowNum 348 } 349 return as, nil 350 } 351 352 func (a *approveSheet) fullName(row []string) string { 353 fi, li := a.headerIndexes[firstNameColumn], a.headerIndexes[lastNameColumn] 354 first, last := row[fi], row[li] 355 if first == "" && last == "" { 356 return "MISSING NAME" 357 } 358 return fmt.Sprintf("%s %s", first, last) 359 } 360 361 func (a *approveSheet) studentNum(row []string) string { 362 return row[a.headerIndexes[studentNumColumn]] 363 } 364 365 func (a *approveSheet) lookupStudentByRow(rowNum int) string { 366 return a.fullName(a.rows[rowNum-2]) 367 } 368 369 func (a *approveSheet) lookupRow(studNum string) (int, error) { 370 if rowNum, ok := a.approveStudMap[studNum]; ok { 371 return rowNum, nil 372 } 373 return 0, fmt.Errorf("not found: %s", studNum) 374 } 375 376 func (a *approveSheet) lookupRowByName(name string) (int, error) { 377 if rowNum, ok := a.approveNameMap[name]; ok { 378 return rowNum, nil 379 } 380 return partialMatch(name, a.approveNameMap) 381 } 382 383 func (a *approveSheet) setApproveCell(rowNum int, approveValue string) { 384 a.approveMap[a.approveCell(rowNum)] = approveValue 385 } 386 387 func (a *approveSheet) approveCell(rowNum int) string { 388 return fmt.Sprintf("%s%d", a.headerLabels[approvedColumn], rowNum) 389 } 390 391 func loadApproveSheet(courseCode string) (*approveSheet, error) { 392 // The approve sheet is a single sheet Excel file with five columns: 393 // 394 // First name | Last name | Student number | Candidate number | Approval 395 // -----------+-----------+-------------------+------------------+---------- 396 // <first> | <last> | <student_no> | <candidate_no> | <approved> 397 // John | Doe | 123456 | | 398 // 399 // Approval and candidate number columns are empty by default. 400 // The approval column should be filled with either "Godkjent" or "Ikke godkjent". 401 // The candidate number column is irrelevant for approval and can be ignored. 402 // 403 f, err := excelize.OpenFile(fileName(courseCode, srcSuffix)) 404 if err != nil { 405 return nil, err 406 } 407 if f.SheetCount != 1 { 408 return nil, fmt.Errorf("expected a single sheet in %s, got %d", fileName(courseCode, srcSuffix), f.SheetCount) 409 } 410 // we expect only a single sheet; assume that is the active sheet 411 sheetName := f.GetSheetName(f.GetActiveSheetIndex()) 412 rows := f.GetRows(sheetName) 413 as, err := newApproveSheet(sheetName, rows) 414 if err != nil { 415 return nil, fmt.Errorf("parse error in %s: %w", fileName(courseCode, srcSuffix), err) 416 } 417 return as, nil 418 } 419 420 func saveApproveSheet(courseCode, sheetName string, approveMap map[string]string) error { 421 f, err := excelize.OpenFile(fileName(courseCode, srcSuffix)) 422 if err != nil { 423 return err 424 } 425 for cell, approved := range approveMap { 426 f.SetCellValue(sheetName, cell, approved) 427 } 428 return f.SaveAs(fileName(courseCode, dstSuffix)) 429 }