Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to grade bartik #62

Merged
merged 16 commits into from
Oct 3, 2023
71 changes: 71 additions & 0 deletions AzureAD.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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"],
# AAAAAA I THOUGHT I FINALLY ESCAPED ODATA
# No idea why i have to do it like this???
filter=f"employeeId eq '{cwid}'",
)

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
Loading