Skip to content

Commit

Permalink
grading: Add lab grading script
Browse files Browse the repository at this point in the history
This script copies students' grades from a given attendance sheet to the
course's register. It requires unique IDs (such as LDAP accounts) to avoid
overlaps given by other forms of identification.

Signed-off-by: Teodor Dutu <[email protected]>
  • Loading branch information
teodutu committed Oct 23, 2024
1 parent cf0cf96 commit cb0e634
Show file tree
Hide file tree
Showing 5 changed files with 300 additions and 0 deletions.
3 changes: 3 additions & 0 deletions grading/grade-labs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
credentials.json
token.json
course_registers.json
50 changes: 50 additions & 0 deletions grading/grade-labs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Lab Grading

This script can be used to copy student grades from a lab register (named "attendance sheet") to the general register used by a given class.
Both registers need to be Google Spreadsheets.
In both of them, students need to be identifiable by unique IDs, such as LDAP accounts.

The attendance spreadsheet needs to store one worksheet per lab.
The names of these worksheets need to contain the following string within their name: `Lab <lab_num>`.
Note that both `Lab 01` and `Lab 1` are accepted.

## Environment

First, you need to install a couple of packages that are listed in `requirements.txt` file.
You can install them locally by running:

```console
pip3 install -r requirements.txt
```

## Configuration

In order to run the script, you need to configure some files in this folder.

Update the `course-registers.json` file and replace the strings `"TODO"` with the IDs of the registers from the courses you teach.
You can find the ID in the URL of the spreadsheet.
More information can be found [here](https://developers.google.com/sheets/api/guides/concepts).

To interact with Google API you need to generate a OAuth2.0 token.
To do this for the first time we need to create a project on the Google Cloud platform.
More information about how you can create a new project [here](https://cloud.google.com/resource-manager/docs/creating-managing-projects).

After you create the project, you must generate an OAuth2.0 token following instructions from [here](https://support.google.com/cloud/answer/6158849?hl=en).
Download the corresponding `json` format for it save it in a new file named `credentials.json`, placed in this folder.

## Running

### Arguments

- -l/--lab <lab_number>
- -t/--ta <teaching_assistant>
- -c/--course <course_name> (The same from the `course_registers.json`)
- -c/--group <group_name> (The same from the `course_registers.json`)

### Example

Copy all grades for the second lab of the Operating Systems course:

```console
python3 grade.py -l 2 -c Operating-Systems -g 321CAa
```
35 changes: 35 additions & 0 deletions grading/grade-labs/course_registers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"TODO: Course name": {
"register_id": "TODO: the ID of the course register",
"register_ids_col": "TODO: the column of the register sheet that contains the students' IDs",
"register_grade_cols": [
"",
"TODO: the columns of the register sheet that contain the students' grades",
"The first element should be empty if labs start from 1 (not 0) so element i matches lab i"
],
"register_sheets": [
"TODO: The names of the sheets in the register",
"Usually corespond to the names of the series"
],
"ta_col": "TODO: The column of the register sheet that contains the students' TAs",
"attendance_ids": {
"group": "TODO: the ID of the course register"
},
"attendance_ids_col": "TODO: the column of the attendance sheet that contains the students' IDs",
"attendance_grade_col": "TODO: the column of the register sheet that contains the students' grades"
},

"Sample-Course": {
"register_id": "12345abcdef",
"register_ids_col": "AO4:AO",
"register_grade_cols": ["", "U4:U", "V4:V", "W4:W", "X4:X", "Y4:Y", "Z4:Z", "AA4:AA",
"AB4:AB", "AC4:AC", "AD4:AD", "AE4:AE", "AF4:AF", "AG4:AG"],
"register_sheets": ["CA", "CB", "CC", "CD", "Altii"],
"ta_col": "E4:E",
"attendance_ids": {
"326CX": "67890ghijkl"
},
"attendance_ids_col": "C2:C",
"attendance_grade_col": "I2:I"
}
}
189 changes: 189 additions & 0 deletions grading/grade-labs/grade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#! /usr/bin/env python3


import argparse
from os.path import exists
from json import load
from re import search
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials


SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
REGISTER_CONFIG_FILE = "course_registers.json"
CREDENTIALS_FILE = "credentials.json"
TOKEN_FILE = "token.json"


def _get_args():
"""
Parses and returns the command-line arguments.
"""
parser = argparse.ArgumentParser("Reads students' names from an " + \
"attendance sheet (Google Sheets) and writes their grades to the " +\
"class config")
parser.add_argument("-l", "--lab", dest="lab_no", type=int, required=True,
help="Lab number")
parser.add_argument("-t", "--ta", dest="ta", type=str, required=False,
help="TA acronym")
parser.add_argument("-c", "--course", dest="course", type=str, required=True,
help="The acronym of the course in whose config to write the grades.")
parser.add_argument("-g", "--group", dest="group", type=str, required=True,
help="The group whose students to grade.")

return parser.parse_args()


def _login():
"""
Performs TA login using the token.json file, if present.
Otherwise, it uses credentials.json.
"""
creds = None
# The file token.json stores the user's access and refresh tokens, and is
# created automatically when the authorization flow completes for the first
# time.
if exists(TOKEN_FILE):
creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)

# If there are no (valid) credentials available, let the user log in.
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE,
SCOPES)
creds = flow.run_local_server(port=0)
# Save the credentials for the next run
with open(TOKEN_FILE, "w", encoding='ascii') as token:
token.write(creds.to_json())

return build("sheets", "v4", credentials=creds)


def _get_ranges(service, spreadsheet_id, sheet_name, ids_col, grades_col):
"""
Returns the values in the given range of the given sheet.
"""
ranges = [f"{sheet_name}!{ids_col}", f"{sheet_name}!{grades_col}"]
grades = service.spreadsheets().values().batchGet(
spreadsheetId=spreadsheet_id, ranges=ranges).execute()

stud_names = grades["valueRanges"][0]["values"]
stud_grades = grades["valueRanges"][1].get("values", [])
stud_grades += [[]] * (len(stud_names) - len(stud_grades))

return list(zip(stud_names, stud_grades))


def _get_attendees(service, config, group, lab_no):
"""
Returns a list of tuples made up of lab attendees' IDs and their
grades.
"""
all_sheets = service.spreadsheets().get(
spreadsheetId=config["attendance_ids"][group]).execute().get('sheets', '')

for sheet in all_sheets:
if search(f"Lab {lab_no}", sheet["properties"]["title"]):
sheet_name = sheet["properties"]["title"]
break

attend_ranges = _get_ranges(service, config["attendance_ids"][group],
sheet_name, config["attendance_ids_col"], config["attendance_grade_col"])

return [(ent[0][0], ent[1][0])
for ent in filter(lambda s: len(s[0]) == 1, attend_ranges)]


def _get_register_range(service, config, lab_sheet, lab_no):
"""
Returns the following dictionary:
- keys = students' IDs
- values = tuple(index in the grades list, grade)
"""
register_ranges = _get_ranges(service, config["register_id"], lab_sheet,
config["register_ids_col"], config["register_grade_cols"][lab_no])
return { k[0]: (v, i) for i, (k, v) in enumerate(filter(lambda p: len(p[0]) != 0, register_ranges)) }


def _make_value_range(sheet, col, idx, value):
"""
Returns one ValueRange object that writes the given value at col[idx].
"""
pos = col.find(":")
# TODO: [Bug] This assumes actual grades start from a row < 10.
# This holds true for most registers, though.
col_start = int(col[pos - 1 : pos])
col = col[pos + 1 :]

return {
"range": f"{sheet}!{col}{col_start + idx}",
"majorDimension": "ROWS",
"values": [[value]]
}


def main(course, lab_no, goup, ta):
"""
Retrieves the attendance list and grades all students who haven't been
already graded. Also assigns the TA to the subgroup if the ta parameter is
specified.
"""
service = _login()
with open(REGISTER_CONFIG_FILE, "r", encoding='ascii') as config_file:
config = load(config_file)[course]

# Read students who participated in the lab.
students_lab = _get_attendees(service, config, goup, lab_no)

# The skeleton of the request body.
body = {
"valueInputOption": "USER_ENTERED",
"data": [ ],
"includeValuesInResponse": False
}

# Look for the students in all sheets.
for sheet in config["register_sheets"]:
reg_range = _get_register_range(service, config, sheet, lab_no)

if any(map(lambda s: len(s) < 2, students_lab)):
print("You have at least one student that it's not graded in"
"attendance list. Please grade all students before run the script.")
return

for stud, grade in students_lab:
if stud in reg_range and len(reg_range[stud][0]) == 0:
body["data"].append(_make_value_range(sheet,
config["register_grade_cols"][lab_no], reg_range[stud][1], grade))
if ta:
body["data"].append(_make_value_range(sheet,
config["ta_col"], reg_range[stud][1], ta))
elif stud in reg_range:
print(f"Error: student '{stud}' has already been graded for lab {lab_no}.")

# Send the update request.
response = service.spreadsheets().values().batchUpdate(
spreadsheetId=config["register_id"], body=body).execute()

print(f"Class register: https://docs.google.com/spreadsheets/d/{config['register_id']}")

# Print the results.
updated_cells = response.get("totalUpdatedCells", 0)
if (not ta and updated_cells == len(students_lab)) \
or (ta and updated_cells == 2 * len(students_lab)):
print("All students are graded!")
elif updated_cells != 0:
print(f"Modified {updated_cells} cells:")
for resp in response["responses"]:
print(resp["updatedRange"])
else:
print("No cells modified!")


if __name__ == "__main__":
args = _get_args()
main(args.course, args.lab_no, args.group, args.ta)
23 changes: 23 additions & 0 deletions grading/grade-labs/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
cachetools
certifi
charset-normalizer
google-api-core
google-api-python-client
google-auth
google-auth-httplib2
google-auth-oauthlib
googleapis-common-protos
httplib2
idna
install
oauthlib
protobuf
pyasn1
pyasn1-modules
pyparsing
requests
requests-oauthlib
rsa
six
uritemplate
urllib3

0 comments on commit cb0e634

Please sign in to comment.