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

Seating charts - 2024 edition #35

Merged
merged 31 commits into from
Apr 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b9237c3
Image downloader script and more robust roster loading
carl-vbn Mar 2, 2024
6ff85d7
Cleaned up go_brr.py
carl-vbn Mar 2, 2024
90b69e6
Reconciled go_brr with previous commits I forgot to pull
carl-vbn Mar 2, 2024
d04e102
Fixed seatingchart.py
carl-vbn Mar 3, 2024
b74a10a
Linked go_brr and seatingchart
carl-vbn Mar 3, 2024
630aa8c
Added ability to download roster from canvas
carl-vbn Mar 3, 2024
b019098
Updated README.md
carl-vbn Mar 3, 2024
9afdcc5
Updated README.md (again)
carl-vbn Mar 3, 2024
f53afde
Added ASCII art to go_brr.py
carl-vbn Mar 3, 2024
72698f1
Added selected seat counter indicator to HTML
carl-vbn Mar 3, 2024
bece61e
Added layout format documentation to README
carl-vbn Mar 3, 2024
e1fbd6a
Removed extra hashes after section names in README
carl-vbn Mar 25, 2024
7db5b26
Added newlines in README.md
carl-vbn Mar 25, 2024
445c859
Added spaces when concatenating ANSI color escape sequences
carl-vbn Mar 25, 2024
17c4cd9
Import ordering readjusted
carl-vbn Mar 25, 2024
c4915a3
Removed redundant spaces in imagedl.py
carl-vbn Mar 25, 2024
60ce878
imagedl cleanup
carl-vbn Mar 25, 2024
8f03948
rosters.py cleanup and better explanation
carl-vbn Mar 25, 2024
b5f0811
seatingchart.py cleanup
carl-vbn Mar 25, 2024
fe48bdc
Fixed bug introduced in rosters.py cleanup
carl-vbn Mar 25, 2024
44b4767
Fixed error introduced in seatingchart.py
carl-vbn Mar 25, 2024
b35f8e0
imagedl.py improvements
carl-vbn Mar 26, 2024
67b8326
pathlib migration
carl-vbn Mar 26, 2024
d734db8
Simplified file writing using write_bytes
carl-vbn Mar 26, 2024
2a80f18
Removed unused os import
carl-vbn Apr 4, 2024
16c30a6
Added comment about Test Student
carl-vbn Apr 4, 2024
e4f6772
roster rework
carl-vbn Apr 4, 2024
588b43c
Print roster size after downloading
carl-vbn Apr 4, 2024
8315d0c
Removed unused variable
carl-vbn Apr 4, 2024
bb748eb
Added comment about roster format to rosters.py
carl-vbn Apr 4, 2024
a21702a
Removed debugging message
carl-vbn Apr 4, 2024
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
86 changes: 38 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,68 +1,60 @@
seatingcharts
=============
# seatingcharts

Originally written by Chris Mulligan (clm2186) for COMS W3157 Advanced Programming.
A collection of scripts to produce a randomized set of seating assignments.

This python script takes a class roster, classroom layout, and some helper files to produce a random set of seating assignments. The best documentation may be comments in the script itself.

## Scripts

Usage
-----
Update 10/2/2022:
Thing's changed! We regularly get a huge class size (over 300 students.) So XXXurxo wrote a helper script to handle multiple rooms in one shot; here is how to use it:
* `seatingchart.py` - The core of this toolset. Creates seating assignments for a single room and produces HTML output. Originally written by Chris Mulligan (clm2186) for COMS W3157 Advanced Programming.
* `go_brr.py` - Splits a larger roster into sub-rosters, one per room, and calls `seatingchart.py` for each one. Written by XXXurxo.
* `mail.py` - Sends individual seating assignments to students by email
* `imagedl.py` - Downloads students' photos. Written by Carl.
* `rosters.py` - This file serves both as a utility to download student rosters from Canvas and as a local library to parse roster CSV files. Files read by this script are expected to have a header specifying the `SIS Login ID` column for UNIs and the `Student` column for full names. This header will also be added to downloaded rosters. Written by Carl.

1. Download the roster from the Grades tab of courseworks; save it as roster.csv, which contains three columns: Student ID, Student Name, and blank. NO HEADER!!!!! (see sample_roster.csv)
## Usage

2. Download all the images from the photo roster in coursworks by chrome extensions, and save them in the images folder
1. Download the roster CSV file. This can either be done manually by going into the 'Grades' tab on Courseworks and selecting Export > Export Entire Gradebook or by using `rosters.py --download roster_filename.csv` (this requires a Canvas API key)

3. modify the rooms file in this format:
<p>[layout-1] [number of students]</p>
<p>[layout-2] [number of students]</p>
(see sample_room)
2. Download the student images from the 'Photo Roster' tab. This can be achieved either with a browser extension, by using the "Save page as" functionality, or running the `imagedl.py` script.

4. run python3 go_brr.py rooms roster.csv
3. If necessary, edit the `rooms` file (see 'Room format' below)

You can find the output in the out folder :)
4. Run `./go_brr.py rooms roster_filename.csv`

### ### ### ### ### ### ### ### ### ### ### ###
### Downloading the input files
5. Profit! The output can be found in `out/` and will also be placed in `~/html/seating/`

For sample inputs, see the `out/demo/` directory.
## Room format

* In the `out` directory, create a working directory for storing files related
to this exam.
- Example: `3157-2017-9-001_final`
- This string is the _slug_ for this exam.
### rooms file

* Log into [CourseWorks](https://courseworks2.columbia.edu/) using Chrome
or Firefox.
This is the file passed to `go_brr.py`. It follows the following format

* Find your class site.
```
room1name room1seatcount
room2name room2seatcount
...
```

* Student roster
- Go to Grades > Export > CSV File.
- Move this file to the working directory.
- Rename it `roster_<slug>.csv`.
For each room, the `layouts` directory must contain two additional files:
1. The visual layout file, `room1name.txt`
This file determines the general layout of a room as it will be reflected on the HTML seating chart. Seats must be separated by a TAB character

* (Optional) Lefty roster
```
SEAT1 SEAT2 SEAT3 SEAT4 SEAT5 SEAT6
SEAT7 SEAT8 SEAT9 SEAT10 SEAT11 SEAT12
...
```

2. The fill order file, `room1name_ordered.txt`
This file determines in what order to fill seats. Exact semantics don't matter much (i.e. spaces, tabs and line breaks are functionally identical) though lines starting with hash (#) symbols will be ignored.

## Additional features (not yet available with go_brr.py)
* Lefty roster
- Ensure that your classroom has a `<classroom>_lefty_ordered.txt` file.
- Move lefties from `roster_<slug>.csv` to a new file called
`left_roster_<slug>.csv`. This file follows the same format as the normal roster.
- Run `seatingcharts.py` with the `-l` flag.

* Photo roster
- Go to Photo Roster in the menu on the left and wait a minute.
- On Chrome, use File > Save Page As. In the Format drop-down, select
"Web Page, complete".
- On Firefox, right-click inside the Photo Roster panel and select
This Frame > Save Frame As. In the Format drop-down, select "Web Page,
complete".
- Navigate to your working directory, name the file `<slug>.html`
and press Save.
- You should now have an HTML page and a directory of files with all
students' photos, along with some miscellaneous JS files. You do not
need to delete the extra cruft.

* If you want to put some students in the front/back of the classroom, also
create files named `assign-first_<slug>.txt` and `assign-last_<slug>.txt`.
- Files should contain newline-separated lists of UNIs.
Expand Down Expand Up @@ -112,8 +104,7 @@ Once the script runs, it will output:
`mail.py`.


Emailing students their seating assignments
-------------------------------------------
### Emailing students their seating assignments

You can now use mail.py to send individual emails to students with their seat assignment.

Expand All @@ -123,8 +114,7 @@ You can now use mail.py to send individual emails to students with their seat as
* Change the name and email in the script.


Adding support for new classrooms
---------------------------------
### Adding support for new classrooms

In general, the script works by:

Expand Down
70 changes: 48 additions & 22 deletions go_brr.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,35 @@
import os
import random
import shutil
import traceback
from pathlib import Path

import csv2pdf

import rosters
import seatingchart

OUT_PATH = Path.cwd() / "out"
HTML_PATH = Path.home() / "html" / "seating"
HTML_IMAGES_PATH = HTML_PATH / "images"
HTML_PDF_PATH = HTML_PATH / "seat.pdf"

# ANSI color codes
GREEN = "\033[01;92m"
RED = "\033[01;91m"
END = "\033[0m"

ASCII_ART = r"""
________ ________ ________ ________ ________
|\ ____\|\ __ \ |\ __ \|\ __ \|\ __ \
\ \ \___|\ \ \|\ \ \ \ \|\ /\ \ \|\ \ \ \|\ \
\ \ \ __\ \ \\\ \ \ \ __ \ \ _ _\ \ _ _\
\ \ \|\ \ \ \\\ \ \ \ \|\ \ \ \\ \\ \ \\ \|
\ \_______\ \_______\ \ \_______\ \__\\ _\\ \__\\ _\
\|_______|\|_______| \|_______|\|__|\|__|\|__|\|__|

"""

def rename_images(img_path: Path) -> None:
for img in img_path.glob("*"):
if img.suffix == ".jpeg":
Expand All @@ -37,16 +57,17 @@ def init_seat_csv(seat_csv_path: Path) -> None:


def main():
print(ASCII_ART)

parser = argparse.ArgumentParser(description="Go brrr with the seating charts")
parser.add_argument("rooms", type=str, metavar='<rooms>')
parser.add_argument("roster", type=str, metavar='<roster>')
parser.add_argument("rooms", type=str, metavar='<rooms_file>')
parser.add_argument("roster", type=str, metavar='<roster_file>')
args = parser.parse_args()

ROOM_FILE = Path(args.rooms)
STUDENT_FILE = Path(args.roster)

with STUDENT_FILE.open() as f:
students = [tuple(s) for s in csv.reader(f)]
students = rosters.load_roster(args.roster)
print(f"Found {len(students)} students in the roster file")

random.shuffle(students)
student_count = len(students)
Expand All @@ -63,8 +84,10 @@ def main():
with ROOM_FILE.open('r') as f:
rooms = [r.strip().split() for r in f.readlines() if r.strip()]

print(f"Rooms: {rooms}")
for _, count in rooms:
print("Rooms:")

for room_name, count in rooms:
print(f"- {room_name}: {count} seats")
seat_count += int(count)
print(f"Total seats: {seat_count}")

Expand All @@ -84,20 +107,22 @@ def main():
ROOM_OUT_PATH.mkdir(parents=True)
ROOM_IMAGES_PATH.symlink_to(Path("images"))

with ROOM_ROSTER_PATH.open("w", newline='') as csvfile:
output = csv.writer(csvfile)
num_seats = math.ceil(student_count / seat_count * int(rcount))
for _ in range(num_seats):
if student_index < student_count:
student = students[student_index]
student_index += 1
output.writerow(student)
csvfile.flush()

# Check if the os.system call is successful
if os.system(f"./seatingchart.py {rname} {rname}") != 0:
print("\033[01;91mError: seatingchart.py failed\033[0m")
return
room_students = []
num_seats = math.ceil(student_count / seat_count * int(rcount))
for _ in range(num_seats):
if student_index < student_count:
student = students[student_index]
student_index += 1
room_students.append(student)

rosters.save_roster(room_students, ROOM_ROSTER_PATH)

try:
seatingchart.run(slug=rname, layout=rname)
except Exception as e:
print(traceback.format_exc())
print(RED + "Error: seatingchart.py failed" + END)
exit(1)

shutil.copy(ROOM_CHART_PATH, HTML_ROOM_PATH)

Expand All @@ -118,7 +143,8 @@ def main():
HTML_PDF_PATH.chmod(0o644)
seat_csv_path.unlink()

print("\033[01;92mSuccess!\033[0m")
print(GREEN + "Success!" + END)

if __name__ == "__main__":
main()

141 changes: 141 additions & 0 deletions imagedl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env python3

import argparse
import re
import requests
from pathlib import Path

import rosters

TITLE_ART = """
____ ___ __
/ _/_ _ ___ ____ ____ / _ \/ /
_/ // ' \/ _ `/ _ `/ -_) // / /__
/___/_/_/_/\_,_/\_, /\__/____/____/
/___/
"""

GREEN = "\033[92m"
YELLOW = "\033[1;33m"
LIGHT_BLUE = "\033[1;34m"
END = "\033[0m"
LINE_CLEAR = "\x1b[2K"
LINE_UP = "\033[1A"

def multiline_input():
lines = []
while (inpt := input()):
lines.append(inpt)

return lines

# Used to extract the image download URL and required headers
# from the cUrl string copied from the browser
def parse_curl(curl):
# Extract the url by finding the content of the first single-quote pair
url = re.search("'([^']*)'", curl[0]).group(1)

headers = {}
for line in curl[1:]:
if line.strip().startswith('-H'):
# Extract the header by once again finding the content of the single-quote pair
header_name, header_value = re.search("'([^']*)'", line).group(1).split(': ')
headers[header_name] = header_value

return url, headers

def do_dl(url_prefix, headers, unis, output_dir):
counter = 0
uni_count = len(unis)
for uni in unis:
url = f'{url_prefix}{uni}.jpg'

counter += 1
percentage = round(counter / uni_count * 100, 1)
print(f"Downloading {uni}.jpg ({counter}/{uni_count} -- {percentage}%)")
print(LINE_UP, end=LINE_CLEAR) # CLear previous line
response = requests.get(url, headers=headers)

(output_dir / f"{uni}.jpg").write_bytes(response.content)

print(GREEN + "Download complete." + END)

last_step_number = 0
def step(instruction, await_input=True):
global last_step_number

(input if await_input else print)(f'[{last_step_number}] {instruction}')
last_step_number += 1

def run_guide(output_dir, roster_filepath, skip_existing=False):
print(TITLE_ART)
print("Python utility to download student images automatically")

if not output_dir.exists():
print("Output directory does not exist. Creating...")
output_dir.mkdir()

print("Loading roster...")
students = rosters.load_roster(roster_filepath)
print(f"Loaded {len(students)} from roster.")
unis = set(map(lambda student: student[0], students))

if skip_existing:
skipped_unis = {file.stem for file in output_dir.iterdir()}.intersection(unis)

unis = unis.difference(skipped_unis)
print(f"{YELLOW}{len(skipped_unis)} UNIs already have images in the output directory and will be skipped.{END}")

uni_count = len(unis)
print(f"Found {uni_count} UNIs")

print(YELLOW + "The instructions were designed for Chromium-based browsers and might differ on other browsers.\n" + END)
print(LIGHT_BLUE + "This utility will guide you through a series of steps. Once you complete a step, press ENTER" + END)
step("Press ENTER to begin")
step("Open canvas/courseworks and go to the relevant course, then open your browser's developer tools. Find the network tab and press 'clear'")
step("Navigate to 'Photo Roster' section on the course canvas page and wait for images to load (this can take a while)")
step("The network tab should fill with requests to images named named after students' unis. Right click one of these requests and select 'Copy as cURL (bash)'. Be careful NOT to select the option saying 'Copy ALL'", await_input=False)
print(LIGHT_BLUE + "Paste the copied text in this window. If the script doesn't continue automatically, press ENTER." + END)

curl = multiline_input()

url, headers = parse_curl(curl)

# Remove the file name from the url
# i.e. "hostname/path/uni.jpg" -> "hostname/path/"
url_prefix = "/".join(url.split('/')[:-1]) + '/'

print("Extracted URL prefix:", url_prefix)
print(f"Extracted {len(headers)} headers:", headers)
print(f"\n{LIGHT_BLUE}The script will now pretend to be your browser and download {uni_count} images to the '{output_dir.as_posix()}' directory.{END}")

if input("Are you ready to begin? (Y/n) ") not in ['Y','y','']:
print('Aborted.')
exit(0)

do_dl(url_prefix, headers, unis, output_dir)


if __name__ == '__main__':
parser = argparse.ArgumentParser(
description="Download student images from Courseworks")

parser.add_argument("--outdir",
type=str,
nargs='?',
metavar='<output-directory>',
default='images')

parser.add_argument("--roster",
type=str,
nargs='?',
default='roster.csv',
metavar='<roster>')

parser.add_argument("--skip-existing",
action='store_true',
default=False)


args = parser.parse_args()
run_guide(output_dir=Path(args.outdir), roster_filepath=Path(args.roster), skip_existing=args.skip_existing)
Loading