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

grading: Add lab grading script #6

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
{
teodutu marked this conversation as resolved.
Show resolved Hide resolved
"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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like something a regex would fix.

# 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