Skip to content
This repository was archived by the owner on Sep 1, 2025. It is now read-only.

Commit 16510b7

Browse files
authored
add pl support (#66)
1 parent 6801c58 commit 16510b7

File tree

5 files changed

+127
-24
lines changed

5 files changed

+127
-24
lines changed

AzureAD.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,24 @@ def __init__(self, tenantId: str) -> None:
3131

3232

3333

34-
async def getCWIDFromEmail(self, username: str) -> str:
35-
query = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
36-
select=["employeeId"],
37-
)
34+
async def getCWIDFromEmail(self, usernames: list[str]) -> list[tuple[str, str]]:
35+
cwidMap = []
3836

39-
requestConfig = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(query_parameters=query)
40-
userCwid = await self.client.users.by_user_id(username).get(requestConfig)
37+
for i in range(0, len(usernames), 14):
38+
query = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
39+
select=["employeeId", "userPrincipalName"],
40+
filter=f"userPrincipalName in ['{'\',\''.join(usernames[i:i+14])}'] and accountEnabled eq true",
41+
)
4142

42-
if userCwid is None or userCwid.employee_id is None:
43-
return ""
43+
requestConfig = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(query_parameters=query)
44+
userCwids = await self.client.users.get(requestConfig)
45+
46+
if userCwids is None:
47+
continue
48+
49+
cwidMap.extend([(val.user_principal_name, val.employee_id) for val in userCwids.value])
4450

45-
return userCwid.employee_id
51+
return cwidMap
4652

4753
async def getEmailFromCWID(self, cwid: str) -> str:
4854
query = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(

FileHelpers/csvLoaders.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# The biggest change that needs to be made is to remap mutlipasses to cwids
2-
2+
import csv
3+
import re
4+
import sys
5+
from typing import List, Dict
36

47
import pandas as pd
58
from FileHelpers import fileHelper
@@ -12,6 +15,7 @@
1215
# Grace Period of 15 minutes
1316
GRADESCOPE_GRACE_PERIOD = 15
1417

18+
csv.field_size_limit(sys.maxsize)
1519

1620
def loadCSV(_filename: str, promptIfError: bool = False, directoriesToCheck: list[str] = None):
1721
"""
@@ -115,6 +119,69 @@ def loadGradescope(_filename):
115119
print("Done.")
116120
return gradescopeDF
117121

122+
def extractGroupFromPL(group: str):
123+
r = re.compile(r"[\[\"\]]")
124+
125+
group = re.sub(r, "", group)
126+
127+
return group.split(",")
128+
129+
def convertGroupSubmissionToIndividualSubmission(header: List[str], data: List[List[str]]):
130+
GROUP_MEMBER_IDX = header.index("Usernames")
131+
SUBMISSION_DATE_INDEX = header.index("Submission date")
132+
QUESTION_POINTS_IDX = header.index("Question points")
133+
134+
normalizedSubmission = []
135+
136+
for line in data:
137+
members = extractGroupFromPL(line[GROUP_MEMBER_IDX])
138+
for member in members:
139+
normalizedSubmission.append([member, line[SUBMISSION_DATE_INDEX], float(line[QUESTION_POINTS_IDX])])
140+
141+
return normalizedSubmission
142+
143+
def parseLinePL(students: Dict[str, List[str]], line: List[str]):
144+
USER_ID_IDX = 0
145+
SUBMISSION_DATE_IDX = 1
146+
POINTS_IDX = 2
147+
148+
if not line[USER_ID_IDX]:
149+
# empty group
150+
return
151+
152+
if line[USER_ID_IDX] in students.keys():
153+
students[line[USER_ID_IDX]][SUBMISSION_DATE_IDX] = line[SUBMISSION_DATE_IDX]
154+
students[line[USER_ID_IDX]][POINTS_IDX] += line[POINTS_IDX]
155+
return
156+
157+
students[line[USER_ID_IDX]] = [line[USER_ID_IDX], line[SUBMISSION_DATE_IDX], line[POINTS_IDX]]
158+
159+
def loadPrairieLearn(filename):
160+
data = []
161+
try:
162+
with open(filename) as r:
163+
reader = csv.reader(r, quotechar='"')
164+
for line in reader:
165+
data.append(line)
166+
except FileNotFoundError:
167+
return pd.DataFrame()
168+
169+
data = convertGroupSubmissionToIndividualSubmission(data[0], data[1:])
170+
171+
scores = {}
172+
173+
for line in data[1:]:
174+
parseLinePL(scores, line)
175+
176+
plDF = pd.DataFrame({
177+
'email': [value[0] for value in scores.values()],
178+
'hours_late': [0 for _ in range(len(scores))],
179+
'Total Score': [value[2] for value in scores.values()],
180+
'Status': ['Graded' for _ in range(len(scores))],
181+
'lateness_comment': ['' for _ in range(len(scores))],
182+
})
183+
184+
return plDF
118185

119186
def loadRunestone(_filename, assignment: str):
120187
"""

Grade/gradesheets.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@
1111
This file **non-destructively** creates gradesheets. The generated gradesheets still have to be 'selected' in Canvas
1212
in order to be scored and posted with everything else.
1313
"""
14+
from typing import List
15+
1416
import pandas as pd
1517

1618
from Bartik.Bartik import Bartik
1719
from AzureAD import AzureAD
1820

19-
async def convertBartikToGradesheet(_azure: AzureAD, _bartik: Bartik, _students: pd.DataFrame, _assignment: str, _maxPoints: float, _requiredProbems: int) -> pd.DataFrame:
21+
22+
async def convertBartikToGradesheet(_azure: AzureAD, _bartik: Bartik, _students: pd.DataFrame, _assignment: str,
23+
_maxPoints: float, _requiredProbems: int) -> pd.DataFrame:
2024
bartikGradesheet: pd.DataFrame = pd.DataFrame()
2125
bartikGradesheet['multipass'] = ""
2226
bartikGradesheet['Total Score'] = ""
@@ -30,12 +34,11 @@ async def convertBartikToGradesheet(_azure: AzureAD, _bartik: Bartik, _students:
3034
print(f"Now grading {row['name']} ({counter}/{len(_students)})...", end="")
3135

3236
studentEmail: str = await _azure.getEmailFromCWID(row['sis_id'])
33-
37+
3438
if studentEmail == "":
3539
print(f"Failed to map email for {row['name']}")
3640
continue
3741

38-
3942
missing: bool = False
4043
score: float = 0
4144

@@ -46,16 +49,15 @@ async def convertBartikToGradesheet(_azure: AzureAD, _bartik: Bartik, _students:
4649
print(f"Missing")
4750

4851
bartikGradesheet = pd.concat([bartikGradesheet, pd.DataFrame(
49-
{
50-
'multipass': row['sis_id'],
51-
'Total Score': score,
52-
'lateness_comment': "",
53-
}, index=[0]
54-
)], ignore_index=True)
52+
{
53+
'multipass': row['sis_id'],
54+
'Total Score': score,
55+
'lateness_comment': "",
56+
}, index=[0]
57+
)], ignore_index=True)
5558

5659
if not missing:
5760
print("Done")
58-
5961

6062
_bartik.closeSession()
6163
return bartikGradesheet
@@ -97,7 +99,6 @@ def createGradesheetForPassFailAssignment(_passFailAssignment: pd.DataFrame, _st
9799
checkProofOfAttendance: bool = False,
98100
proofOfAttendanceColumn: (str, None) = None) \
99101
-> pd.DataFrame:
100-
101102
if proofOfAttendanceColumn:
102103
proofOfAttendanceColumn = proofOfAttendanceColumn.replace(' ', '_')
103104

@@ -141,4 +142,13 @@ def createGradesheetForPassFailAssignment(_passFailAssignment: pd.DataFrame, _st
141142
return _passFailAssignment
142143

143144

145+
async def finalizeGradesheet(azure: AzureAD, assignment: pd.DataFrame):
146+
emails = assignment['email'].tolist()
147+
148+
emailsWithCwids = await azure.getCWIDFromEmail(emails)
149+
assignment['multipass'] = ''
150+
151+
for i, row in assignment.iterrows():
152+
assignment.loc[i, 'multipass'] = [email[1] for email in emailsWithCwids if email[0] == row['email']][0]
144153

154+
return assignment

UI/standardGrading.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ async def standardGrading(**kwargs):
2020
gradesheetsToGrade: dict[int, pd.DataFrame] = uiHelpers.setupGradescopeGrades(kwargs['canvas'])
2121
elif choice == 2:
2222
gradesheetsToGrade: dict[int, pd.DataFrame] = uiHelpers.setupRunestoneGrades(kwargs['canvas'])
23-
elif choice == 3:
24-
return NotImplementedError # TODO add PL support
23+
else:
24+
gradesheetsToGrade: dict[int, pd.DataFrame] = await uiHelpers.setupPLGrades(kwargs['canvas'], kwargs['azure'])
2525

2626
specialCasesDF = uiHelpers.setupSpecialCases()
2727

UI/uiHelpers.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""
22
"""
33
import pandas as pd
4-
from FileHelpers.csvLoaders import loadGradescope, loadRunestone
4+
5+
from AzureAD import AzureAD
6+
from FileHelpers.csvLoaders import loadGradescope, loadRunestone, loadPrairieLearn
57
from FileHelpers.excelLoaders import loadSpecialCases, loadPassFailAssignment
68
from Canvas import Canvas
9+
from Grade.gradesheets import finalizeGradesheet
710

811

912
def getUserInput(allowedUserInput: str = None, allowedLowerRange: int = None, allowedUpperRange: int = None):
@@ -93,6 +96,23 @@ def setupGradescopeGrades(_canvas: Canvas) -> dict[int, pd.DataFrame]:
9396

9497
return assignmentMap
9598

99+
async def setupPLGrades(canvas: Canvas, azure: AzureAD) -> dict[int, pd.DataFrame]:
100+
# the IDs will always be unique per course - using those over the common names
101+
selectedAssignments: pd.DataFrame = canvas.getAssignmentsToGrade()
102+
assignmentMap: dict[int, pd.DataFrame] = {}
103+
if selectedAssignments is None:
104+
return assignmentMap
105+
for i, row in selectedAssignments.iterrows():
106+
print(f"Enter path to pl grades for {row['common_name']}")
107+
path = getUserInput(allowedUserInput="./path/to/pl/grades.csv")
108+
plDf: pd.DataFrame = await finalizeGradesheet(azure, loadPrairieLearn(path))
109+
if plDf.empty:
110+
print(f"Failed to load file '{path}'")
111+
# TODO handle this case more elegantly
112+
return {}
113+
assignmentMap[row['id']] = plDf
114+
115+
return assignmentMap
96116

97117
def setupRunestoneGrades(_canvas: Canvas) -> dict[int, pd.DataFrame]:
98118
# the IDs will always be unique per course - using those over the common names

0 commit comments

Comments
 (0)