Skip to content

Commit

Permalink
Merge pull request #62 from CSCI128/feat_gjbell_bartik_support
Browse files Browse the repository at this point in the history
Add support to grade bartik
  • Loading branch information
Gregory Bell authored Oct 3, 2023
2 parents 4a63504 + 33c95eb commit 2c3a30f
Show file tree
Hide file tree
Showing 23 changed files with 495 additions and 38 deletions.
69 changes: 69 additions & 0 deletions AzureAD.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from typing import List
from azure.identity import AzureCliCredential
from azure.core.credentials import TokenCredential
from msgraph import GraphServiceClient
from msgraph.generated.users.users_request_builder import UsersRequestBuilder


class AzureAD():

SCOPES: List[str] = ["https://graph.microsoft.com/.default"]

@staticmethod
def _authenticate(tenantId: str) -> TokenCredential:
# TODO - We will need to provide hints for this
cred = AzureCliCredential(tenant_id=tenantId)

return cred


@staticmethod
def _createGraphServiceClient(cred: TokenCredential, scopes = SCOPES) -> GraphServiceClient:
client = GraphServiceClient(cred, scopes)

return client



def __init__(self, tenantId: str) -> None:
self.cred = self._authenticate(tenantId)
self.client = self._createGraphServiceClient(self.cred)



async def getCWIDFromEmail(self, username: str) -> str:
query = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=["employeeId"],
)

requestConfig = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(query_parameters=query)
userCwid = await self.client.users.by_user_id(username).get(requestConfig)

if userCwid is None or userCwid.employee_id is None:
return ""

return userCwid.employee_id

async def getEmailFromCWID(self, cwid: str) -> str:
query = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=["userPrincipalName"],
filter=f"employeeId eq '{cwid}' and accountEnabled eq true",
)

requestConfig = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(query_parameters=query)

user = await self.client.users.get(requestConfig)

if user is None:
return ""

if user.value is None or not len(user.value):
return ""

user = user.value[0]

if user.user_principal_name is None:
return ""

return user.user_principal_name

20 changes: 20 additions & 0 deletions Bartik/Assignments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from datetime import datetime

from Bartik.Base import Base
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

class Assignments(Base):
__tablename__ = "assignments"

id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Technically this is nullable, but there are no instances in the DB where it is null
name: Mapped[str]
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
due_date: Mapped[datetime]
hidden: Mapped[bool] = mapped_column(default=False)
description: Mapped[str]
course_id: Mapped[int] = mapped_column(index=True)


11 changes: 11 additions & 0 deletions Bartik/AssignmentsProblemsMap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from Bartik.Base import Base
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

class AssignmentsProblemsMap(Base):
__tablename__ = "assignments_problems"

# Composite keys have to both be marked as the primary
# ffs this took like 2 hours of debugging
assignment_id: Mapped[int] = mapped_column(primary_key=True, index=True)
problem_id: Mapped[int] = mapped_column(primary_key=True, index=True)
114 changes: 114 additions & 0 deletions Bartik/Bartik.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from typing import List, Optional
from sqlalchemy import create_engine, Engine, select
from sqlalchemy.orm import Session, sessionmaker
from Bartik.Assignments import Assignments
from Bartik.AssignmentsProblemsMap import AssignmentsProblemsMap
from Bartik.Courses import Courses
from Bartik.Users import Users
from Bartik.Grades import Grades
from Bartik.Base import Base


class Bartik():

def __init__(self, _url: str, _userName: str, _password: str, courseId: Optional[int] = None) -> None:
CONNECTION_STRING: str = f"postgresql+psycopg://{_userName}:{_password}@{_url}/autograder"

self.engine: Engine = create_engine(CONNECTION_STRING)

self.BoundSession = sessionmaker(bind=self.engine)

self.session: Optional[Session] = None

self.COURSE_ID = courseId

Base.metadata.create_all(self.engine)


def openSession(self):
if self.session is not None:
return

self.session = self.BoundSession()

def closeSession(self):
if self.session is None:
return

self.session.close()

def getCourseId(self, _courseName: str) -> int:
if self.session is None:
raise Exception("Session must be started")

courseIdStm = select(Courses).where(Courses.name.like(f"%{_courseName}%"), Courses.active==True)
courseIdCourse = self.session.scalars(courseIdStm).first()

if courseIdCourse is None:
raise Exception("Failed to locate course")

return courseIdCourse.id



def getScoreForAssignment(self, _email: str, _assessment: str, requiredProblems: int = 3, maxScore: float = 10) -> float:
if self.session is None:
raise Exception("Session must be started")

assessmentIdStm = select(Assignments).where(Assignments.name.like(f"%{_assessment}%"), Assignments.course_id == self.COURSE_ID)
assessmentIdAssessment = self.session.scalars(assessmentIdStm).first()

if assessmentIdAssessment is None:
raise Exception("Failed to locate assignment")

assessmentId: int = assessmentIdAssessment.id

problemsIdStm = select(AssignmentsProblemsMap).where(AssignmentsProblemsMap.assignment_id == assessmentId)

problemsIdProblems = self.session.scalars(problemsIdStm).all()

if problemsIdProblems is None or not len(problemsIdProblems):
raise Exception("Failed to locate problems for assignment")

problemIds: List[int] = [problemId.problem_id for problemId in problemsIdProblems if problemId is not None]

userIdStm = select(Users).where(Users.email == _email)
userIdUser = self.session.scalars(userIdStm).first()

if userIdUser is None:
raise Exception(f"Failed to find user with email {_email}")

userId = userIdUser.id

gradesStm = select(Grades).where(Grades.problem_id.in_(problemIds), Grades.user_id == userId)
grades = self.session.scalars(gradesStm).all()
# Rn i dont really have the motivation to parse the required problems

totalScore: float = 0

for grade in grades:
totalScore += grade.score if grade is not None else 0

# bartik reports scores out of 10 per problem, scale down so they are now out of 1

totalScore /= 10

totalScore = round((totalScore / requiredProblems) * maxScore, 2)

return totalScore if totalScore <= maxScore else maxScore
















5 changes: 5 additions & 0 deletions Bartik/Base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
pass
15 changes: 15 additions & 0 deletions Bartik/Courses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from datetime import datetime
from Bartik.Base import Base
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

class Courses(Base):
__tablename__ = "courses"

id: Mapped[int] = mapped_column(primary_key=True, index=True)
course_id: Mapped[str]
name: Mapped[str]
term: Mapped[str]
active: Mapped[bool]
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
17 changes: 17 additions & 0 deletions Bartik/Grades.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from datetime import datetime
from Bartik.Base import Base
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

class Grades(Base):
__tablename__ = "grades"

id: Mapped[int] = mapped_column(primary_key=True, index=True)
score: Mapped[int]
user_id: Mapped[int] = mapped_column(index=True)
problem_id: Mapped[int] = mapped_column(index=True)
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
assignment_id: Mapped[int]
course_id: Mapped[int]

35 changes: 35 additions & 0 deletions Bartik/Problems.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from datetime import datetime
from typing import Optional

from sqlalchemy import BigInteger, DateTime
from Bartik.Base import Base
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

class Users(Base):
__tablename__ = "users"

id: Mapped[int] = mapped_column(primary_key=True, index=True)
description: Mapped[str]
name: Mapped[str]
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
hover_description: Mapped[str]
tests_json: Mapped[str]
spec: Mapped[str]
hidden_tests: Mapped[str]
function_name: Mapped[str]
in_type: Mapped[str]
out_type: Mapped[str]
programming_language: Mapped[str]
header: Mapped[str]
input_conversion_functions: Mapped[str]
output_conversion_function: Mapped[str]
text_files: Mapped[str]
input_or_output: Mapped[str]
text_filename: Mapped[str]
input_method: Mapped[str]
output_method: Mapped[str]
input_filename: Mapped[str]
output_filename: Mapped[str]

30 changes: 30 additions & 0 deletions Bartik/Users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from datetime import datetime
from typing import Optional
from Bartik.Base import Base
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column


class Users(Base):
__tablename__ = "users"

id: Mapped[int] = mapped_column(primary_key=True, index=True)
multipass_id: Mapped[str]
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
email: Mapped[str] = mapped_column(default="", unique=True, index=True)
provider: Mapped[str]
uid: Mapped[str]
name: Mapped[str]
role: Mapped[str]
theme: Mapped[str] = mapped_column(default="chrome")
keybind: Mapped[str] = mapped_column(default="ace")
current_course_id: Mapped[int]
successColor: Mapped[str] = mapped_column(default="#5cb85c")
dangerColor: Mapped[str] = mapped_column(default="#d9534f")
infoColor: Mapped[str] = mapped_column(default="#5bc0de")
warningColor: Mapped[str] = mapped_column(default="#f0ad4e")
incorrectBar: Mapped[bool] = mapped_column(default=False)



8 changes: 4 additions & 4 deletions Canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,6 @@ def getStudentsFromCanvas(self):
print(f"\tDownloaded {len(result)} students")

studentList: list[dict] = []
# So when the registrar adds students to a class, we don't have their CWID or multipass
# Naturally, this isn't documented anywhere so through trial and error I found the fields that will always
# be included when we pull the students.
print("\tProcessing students...", end='')
invalidStudents: int = 0
for student in result:
Expand All @@ -337,7 +334,10 @@ def getStudentsFromCanvas(self):
parsedStudent = dict()
parsedStudent['name'] = student['name']
parsedStudent['id'] = student['id']
parsedStudent['sis_id'] = student['email'].split('@')[0]
# Despite this sis_id - it actually is the CWID.
# Thanks mines for phasing out multipass
parsedStudent['sis_id'] = student['sis_user_id']
# parsedStudent['sis_id'] = student['email'].split('@')[0]
studentList.append(parsedStudent)

if invalidStudents != 0:
Expand Down
19 changes: 9 additions & 10 deletions FileHelpers/csvLoaders.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# The biggest change that needs to be made is to remap mutlipasses to cwids


import pandas as pd
from FileHelpers import fileHelper

# When dropping all the unnecessary rows, drop all but these guys ALWAYS.
# Exceptions (like for canvas we want to drop all but the assignment we are posting) exist,
# and are handled in each function
GRADESCOPE_NEVER_DROP = ['Email', 'Total Score', 'Status', 'Lateness']

GRADESCOPE_NEVER_DROP = ['SID', 'Total Score', 'Status', 'Lateness']
# Grace Period of 15 minutes
GRADESCOPE_GRACE_PERIOD = 15

Expand Down Expand Up @@ -107,12 +111,7 @@ def loadGradescope(_filename):
# All NaN values should be handled at this point
gradescopeDF = gradescopeDF.astype({'hours_late': "float"}, copy=False)

# Get multipass from email
for i, row in gradescopeDF.iterrows():
gradescopeDF.at[i, 'Email'] = row['Email'].split('@')[0]
# this approach doesn't work as great if students aren't in gradescope with their correct emails, but I digress

gradescopeDF.rename(columns={'Email': 'multipass'}, inplace=True)
gradescopeDF.rename(columns={'SID': 'multipass'}, inplace=True)
print("Done.")
return gradescopeDF

Expand All @@ -137,7 +136,6 @@ def loadRunestone(_filename, assignment: str):

# get the points out first; row 1 has the total point value
column = runestoneDF.columns.get_loc(assignment)
totalPoints = int(runestoneDF.iloc[1][column])

# drop due date, points, and class average rows (we just want user data)
runestoneDF = runestoneDF.drop([0, 1, 2])
Expand All @@ -160,10 +158,11 @@ def loadRunestone(_filename, assignment: str):

# Get multipass from email
for i, row in runestoneDF.iterrows():
# TODO UPDATE for CWID
runestoneDF.at[i, 'E-mail'] = row['E-mail'].split('@')[0]

# derive actual score from total points and score percentage
score = (totalPoints / 100) * float(row[assignment].split("%")[0])
# derive actual score from total points and score percentage - scale to 4 pts (reading pt amount)
score = (float(row[assignment].split("%")[0]) / 100) * 4
runestoneDF.at[i, assignment] = score

# add phony columns for gradesheet format
Expand Down
Loading

0 comments on commit 2c3a30f

Please sign in to comment.