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  }