Skip to content

Instantly share code, notes, and snippets.

@niflostancu
Created January 14, 2023 12:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save niflostancu/b4ac5bff58dd98a64fb6f5115036f335 to your computer and use it in GitHub Desktop.
Save niflostancu/b4ac5bff58dd98a64fb6f5115036f335 to your computer and use it in GitHub Desktop.
Moodle submission unarchive + rename tool.
#!/usr/bin/env python3
"""
Moodle assignment directory rename script: renames student submission
directories to the user ID / email and optionally extracts the archives inside.
Note: requires a grading CSV exported for the assignment. You need to enable it
in Assignment Settings -> Feedback Types -> Offline Grading Worksheet, then
download it from View All Submission -> Download Grading Worksheet.
Grading directories must be named as '<Full Name>_<ID>_<submission suffix>'.
Invocation: tool.py [options] <submissions dir> <grading csv>
"""
import sys
import os
import os.path
import shutil
import csv
import re
import traceback
import zipfile
import unicodedata
import argparse
submission_dir_re = re.compile(r'^(?P<lname>[a-zA-Z0-9 -]+)\s+(?P<fname>[A-Z0-9-]+)_(?P<id>[0-9]+)_')
archive_formats = ['zip', 'tar', 'tar.gz', 'tar.bz', 'tar.xz'] # avoid students trolling
def strip_accents(s):
""" Strips the unicode accents (diacritics) from a string. """
return ''.join(c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn')
def read_grading_worksheet(csv_filename):
"""
Reads the grading worksheet CSV and returns the students map containing
id, fullname and email.
"""
students_map = {}
with open(csv_filename, newline='') as f:
csvr = csv.reader(f, delimiter=',')
for idx, line in enumerate(csvr):
if idx == 0: # first row is a header
idx += 1
continue
idx += 1
if len(line) < 3:
continue
student_id = re.sub(r'[^0-9]', '', line[0].strip())
email = line[2].strip()
students_map[str(student_id)] = {
# note: ID column contains a text prefix, so exract just the number
"id": student_id,
"fullname": line[1].strip(),
"email": email,
}
return students_map
def search_moodle_username(user_id, students_map):
"""
Searches for the submission username inside the grading worksheet.
"""
search_id = str(user_id)
if search_id in students_map:
return students_map[search_id]
return None
def main(args):
rdir = args.directory
if not os.path.isdir(rdir):
print("Invalid directory: '{}'".format(rdir))
sys.exit(1)
# Load the exported grading worksheet CSV
students_map = None
rename_type = args.rename
if args.sheet:
students_map = read_grading_worksheet(args.sheet)
if not rename_type:
rename_type = 'email'
if not rename_type:
rename_type = 'fname'
if rename_type == 'email' and not students_map:
raise Exception("Cannot rename with emails without a grading worksheet!")
# iterate through all directories and rename / extract them
sub_dirs = os.listdir(rdir)
for sub_dir in sub_dirs:
full_path = os.path.join(rdir, sub_dir)
if not os.path.isdir(full_path):
continue
norm_name = strip_accents(sub_dir)
re_matches = submission_dir_re.match(norm_name)
if re_matches:
new_name = None
if rename_type in ("email", "username"):
student_obj = search_moodle_username(re_matches.group("id"), students_map)
if student_obj:
email = student_obj["email"]
if rename_type == "username":
email = re.sub(r'@\S+$', '', email)
new_name = email
elif rename_type == "fname":
# rename to first_name . last_name
new_name = re_matches["fname"] + " " + re_matches["lname"]
if new_name:
print("Renaming '{}' to '{}'".format(sub_dir, new_name))
if not args.dry_run:
new_path = os.path.join(rdir, new_name)
os.rename(full_path, new_path)
full_path = new_path
if not new_name:
print("NOT renaming '{}'".format(sub_dir))
else:
print("NOT renaming '{}'".format(sub_dir))
if args.extract:
# find archive file
subdir_files = os.listdir(full_path)
if len(subdir_files) != 1:
print("unzip: IGNORE '{}' (multiple files found)".format(sub_dir))
continue
archive_file = os.path.join(full_path, subdir_files[0])
_, archive_ext = os.path.splitext(archive_file)
if not archive_ext[1:]:
print("unzip: IGNORE '{}' (not an archive)".format(archive_file))
continue
print("unzip: '{}'".format(archive_file))
if not args.dry_run:
try:
shutil.unpack_archive(archive_file, full_path)
os.remove(archive_file)
except:
traceback.print_exc()
if __name__ == "__main__":
# Check arguments
parser = argparse.ArgumentParser()
parser.add_argument("--sheet", "-w", help="Path to the grading worksheet to use for renaming (csv format)")
parser.add_argument("--extract", "-x", action='store_true', help="Extract archives")
parser.add_argument(
"--rename", "-r", nargs="?", choices=['none', 'fname', 'email', 'username'],
help="Rename directories using the given method " +
"(default is 'fname', 'email' if --sheet is given)")
parser.add_argument("--dry-run", "-n", action='store_true', help="Do a dry run (take no disk actions)")
parser.add_argument("directory", help="The submissions directory.")
args = parser.parse_args()
main(args)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment