-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
5 changed files
with
300 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
credentials.json | ||
token.json | ||
course_registers.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |