18
0

5 Commits

Author SHA1 Message Date
f1f29cdbe2 Merge remote-tracking branch 'flask_repo/main' into flask 2024-09-28 12:38:06 -07:00
e756c448c0 added flask app from jeremy 2024-09-28 12:32:19 -07:00
Jeremy Foote
aee2c90d1e Adding public vs. private interface 2024-07-03 13:22:50 -04:00
Jeremy Foote
79f9b632b1 Removing test file 2023-03-02 15:17:05 -05:00
Jeremy Foote
23b91425d8 Initial version of coldcalling app 2023-03-02 15:16:39 -05:00
20 changed files with 448 additions and 533 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
*~
__pycache__
*.pyc

View File

@@ -1,127 +0,0 @@
I keep my entire data directory in git and I'd recommend that you do
too. Just make sure you don't commit and publish student records into
the public git repository. I usually just keep a separate branch for
classes.
Daily Process
================================
1. Open your terminal (on Windows, this will likely be powershell in anaconda)
2. Change into the directory with the coldcall scripts.
3. Download new data with: `python download_student_info.py`
This will download the latest version of absence data into `data/optout_poll_data.tsv` as well as th student information into `data/student_information.tsv`.
If you noticed any changes you need to make (e.g., the same preferred names, incorrectly entered absences, etc) you should edit the Google sheets and then running the download again with the same script.
4. When you're ready, fun the main script in the same directory: python coldcallbot-manual.py
This will both:
- output a paper list in terminal. I often redirect this to a file like: `python coldcallbot-manual.py > data/paper_call_list-2024-09-26.txt` or similar.
- Create the computed call list in the `data/` folder
During case, I take notes on student answers on paper during class (typically I
only note down non "GOOD" answers) and then add these to the sheet
immediately after class.
After class each day, you need to open up "call_list-YYYY-MM-DD.tsv"
and edit the two columns in which you store the results of the
case. The first columns `answered` means that the person responded and
answered the question (i.e., they were present in the room but away
from their computer and unresponsive). This is almost always TRUE but
would be FALSE if the student were missing.
The assessment column should be is "GOOD", "SATISFACTORY", "POOR", "NO
MEANINGFUL ANSWER" or "ABSENT" but you can do whatever makes sense in
this and we can work with it when it comes to grading. Just make sure
you are consistent!
Details on my rubric is here:
https://wiki.communitydata.science/User:Benjamin_Mako_Hill/Assessment#Rubric_for_case_discussion_answers
Assessment and Tracking
======================================
These scripts rely on a file in this repository called
`data/student_information.csv` which I have set to be downloaded
automatically from a Google form using the download script.
I don't expect that these will necessary work without
modification. It's a good idea to go line-by-line through these to
make sure they are doing what *you* want and that you agree with the
assessment logic built into this.
For reference, that file has the following column labels (this is the
full header, in order):
Timestamp
Your UW student number
Name you'd like to go by in class
Your Wikipedia username
Your username on the class Discord server
Preferred pronouns
Anything else you'd like me to know?
The scripts in this directory are meant to be run or sourced *from*
the data directory. As in:
$ cd ../data
$ R --no-save < ../assessment_and_tracking/track_participation.R
There are three files in that directory:
track_enrolled.R:
This file keeps track of who is in Discord, who is enrolled for
the class, etc. This helps me remove people from the
student_informaiton.csv spreadsheet who are have dropped the
class, deal with users who change their Discord name, and other
things that the scripts can't deal with automatically.
This all need to be dealt with manually, one way or
another. Sometimes by modifying the script, sometimes by modifying
the files in the data/ directory.
This requires an additional file called
`myuw-COM_482_A_autumn_2020_students.csv` which is just the saved
CSV from https://my.uw.edu which includes the full class list. I
download this one manually.
track_participation.R:
This file generates histograms and other basic information about
the distribution of participation and absences. I've typically run
this weekly after a few weeks of the class and share these images
with students at least once or twice in the quarter.
This file is also sourced by compute_final_case_grades.R.
compute_final_case_grades.R:
You can find a narrative summary of my assessment process here:
https://wiki.communitydata.science/User:Benjamin_Mako_Hill/Assessment#Overall_case_discussion_grade
This also requires the registration file (something like
`myuw-COM_482_A_autumn_2020_students.csv`) which is described
above.
To run this script, you will need to create the following subdirectories:
data/case_grades
data/case_grades/student_reports
One final note: A bunch of things in these scripts assumes a UW 4.0
grade scale. I don't think it should be hard to map these onto some
other scale, but that's an exercise I'll leave up to those that want
to do this.
5. after class, update the call list in the data folder to remove lines for any call that didn't happen (or you don't want to count) and update the assessments:

View File

@@ -1,148 +1,72 @@
## load in the data
#################################
myuw <- read.csv("data/2025_autumn_COMMLD_570_A_students.csv", stringsAsFactors=FALSE)
current.dir <- getwd()
source("assessment_and_tracking/track_participation.R")
setwd(current.dir)
rownames(d) <- d$unique.name
call.list$timestamp <- as.Date(call.list$timestamp)
myuw <- read.csv("myuw-COMMLD_570_A_spring_2021_students.csv", stringsAsFactors=FALSE)
## class-level variables
gpa.point.value <- 50/(4 - 0.7)
## question.grades <- c("GOOD"=100, "FAIR"=100-gpa.point.value, "BAD"=100-(gpa.point.value*2))
question.grades <- c("GOOD"=100, "SATISFACTORY"=100-gpa.point.value, "POOR"=100-(gpa.point.value*2), "NO MEANINGFUL ANSWER"=0)
missed.question.penalty <- gpa.point.value * 0.2 ## 1/5 of a full point on the GPA scale
question.grades <- c("GOOD"=100, "FAIR"=100-(50/3.3), "WEAK"=100-(50/(3.3)*2))
## inspect set the absence threashold
ggplot(d) + aes(x=absences) + geom_histogram(binwidth=1, fill="white",color="black")
absence.threshold <- median(d$absences)
source("../assessment_and_tracking/track_participation.R")
setwd("case_grades")
## inspect and set the questions cutoff
## questions.cutoff <- median(d$num.calls)
## median(d$num.calls)
## questions.cutoff <- nrow(call.list) / nrow(d) ## TODO talk about this
## this is the 95% percentile based on simulation in simulation.R
questions.cutoff <- 34
rownames(d) <- d$unique.name
## show the distribution of assessments
table(call.list$assessment)
prop.table(table(call.list$assessment))
table(call.list.full$answered)
prop.table(table(call.list.full$answered))
table(call.list$answered)
prop.table(table(call.list$answered))
total.questions.asked <- nrow(call.list)
## find out how man questions folks have present/absent for.
##
## NOTE: this is currently only for informational purposes and is NOT
## being used to compute grants in any way.
########################################################################
calls.per.day <- data.frame(day=as.Date(names(table(call.list$timestamp))),
questions.asked=as.numeric(table(call.list$timestamp)))
## function to return the numbers of calls present for or zero if they
## were absent
calls.for.student.day <- function (day, student.id) {
if (any(absence$unique.name == student.id & absence$date.absent == day)) {
return(0)
} else {
return(calls.per.day$questions.asked[calls.per.day$day == day])
}
}
compute.questions.present.for.student <- function (student.id) {
sum(unlist(lapply(unique(calls.per.day$day), calls.for.student.day, student.id)))
}
## create new column with number of questions present
d$q.present <- unlist(lapply(d$unique.name, compute.questions.present.for.student))
d$prop.asked <- d$num.calls / d$q.present
## generate statistics using these new variables
prop.asks.quantiles <- quantile(d$prop.asked, probs=seq(0,1, 0.01))
prop.asks.quantiles <- prop.asks.quantiles[!duplicated(prop.asks.quantiles)]
d$prop.asked.quant <- cut(d$prop.asked, right=FALSE, breaks=c(prop.asks.quantiles, 1),
labels=names(prop.asks.quantiles)[1:(length(prop.asks.quantiles))])
## generate grades
########################################################################
##########################################################
d$part.grade <- NA
## print the median number of questions for (a) everybody and (b)
## people that have been present 75% of the time
median(d$num.calls)
questions.cutoff <- median(d$num.calls)
## helper function to generate average grade minus number of missing
gen.part.grade <- function (x.unique.name) {
q.scores <- question.grades[call.list$assessment[call.list$unique.name == x.unique.name]]
print(q.scores)
base.score <- mean(q.scores, na.rm=TRUE)
## number of missing days
missing.in.class.days <- nrow(missing.in.class[missing.in.class$unique.name == x.unique.name,])
# missing.days <- nrow(missing.in.class[missing.in.class$unique.name == x.unique.name,])
## return the final score
data.frame(unique.name=x.unique.name,
base.grade=base.score,
missing.in.class.days=missing.in.class.days)
part.grade=(base.score))
}
## create the base grades which do NOT include missing questions
tmp <- do.call("rbind", lapply(d$unique.name, gen.part.grade))
d <- merge(d, tmp)
rownames(d) <- d$unique.name
d$part.grade <- d$base.grade
## first we handle the zeros
## step 1: first double check the people who have zeros and ensure that they didn't "just" get unlucky"
d[d$num.calls == 0,]
d[as.character(tmp$unique.name), "part.grade"] <- tmp$part.grade
## set those people to 0 :(
d$part.grade[d$num.calls == 0] <- 0
## generate the baseline participation grades as per the process above
## step 2: identify the people who were were not asked "enough"
## questions but were unlucky/lucky
## first this just prints out are the people were were not called
## simply because they got unlucky
d[d$num.calls < questions.cutoff & d$absences < absence.threshold,]
## these are the people were were not called simply unlucky (i.e.,
## they were not in class very often)
penalized.unique.names <- d$unique.name[d$num.calls < questions.cutoff & d$absences > absence.threshold]
d[d$unique.name %in% penalized.unique.names,]
## now add "zeros" for every questions that is below the normal
d[as.character(penalized.unique.names),"part.grade"] <- (
(d[as.character(penalized.unique.names),"num.calls"] * d[as.character(penalized.unique.names),"part.grade"])
/ questions.cutoff)
d[as.character(penalized.unique.names),]
## apply the penality for number of days we called on them and they were gone
d$part.grade <- d$part.grade - d$missing.in.class.days * missed.question.penalty
## TODO ensure this is right. i think it is
## map part grades back to 4.0 letter scale and points
d$part.4point <- round((d$part.grade / gpa.point.value) - ((100 / gpa.point.value) - 4), 2)
d$part.4point <-round((d$part.grade / (50/3.3)) - 2.6, 2)
d[sort.list(d$part.4point, decreasing=TRUE),
c("unique.name", "short.name", "num.calls", "absences", "part.4point")]
d[sort.list(d$part.4point),]
## writing out data to CSV
## writing out data
d.print <- merge(d, myuw[,c("StudentNo", "FirstName", "LastName", "UWNetID")],
by.x="unique.name", by.y="StudentNo")
write.csv(d.print, file="data/final_participation_grades.csv")
by.x="student.num", by.y="StudentNo")
write.csv(d.print, file="final_participation_grades.csv")
library(rmarkdown)
## library(rmarkdown)
for (id in d$unique.name) {
render(input="assessment_and_tracking/student_report_template.Rmd",
output_format="html_document",
output_file=paste(current.dir, "/data/case_grades/",
d.print$unique.name[d.print$unique.name == id],
sep=""))
}
## for (x.unique.name in d$unique.name) {
## render(input="../../assessment_and_tracking/student_report_template.Rmd",
## output_format="html_document",
## output_file=paste("../data/case_grades/student_reports/",
## d.print$UWNetID[d.print$unique.name == x.unique.name],
## sep=""))
## }

View File

@@ -1,24 +0,0 @@
weight.fac <- 2
num.calls <- 373
num.students <- 76
gen.calls.per.students <- function (x) {
raw.weights <<- rep(1, num.students)
names(raw.weights) <- seq(1, num.students)
table(sapply(1:num.calls, function (i) {
probs <- raw.weights / sum(raw.weights)
selected <- sample(names(raw.weights), 1, prob=probs)
## update the raw.weights
raw.weights[selected] <<- raw.weights[selected] / weight.fac
#print(raw.weights)
return(selected)
}))
}
simulated.call.list <- unlist(lapply(1:1000, gen.calls.per.students))
hist(simulated.call.list)
quantile(simulated.call.list, probs=seq(0,1,by=0.01))
quantile(simulated.call.list, probs=0.05)

View File

@@ -1,19 +1,22 @@
**Student Name:** `r paste(d.print[d.print$unique.name == id, c("LastName", "FirstName")])` (`r id`)
**Student Name:** `r paste(d.print[d.print$discord.name == x.discord.name, c("FirstName", "LastName")])`
**Participation grade:** `r d.print$part.4point[d.print$unique.name == id]`
**Discord Name:** `r d.print[d.print$discord.name == x.discord.name, c("discord.name")]`
**Questions asked:** `r d.print[d$unique.name == id, "num.calls"]`
**Participation grade:** `r d.print$part.4point[d.print$discord.name == x.discord.name]`
**Days Absent:** `r d.print[d.print$unique.name == id, "absences"]` / `r length(unique(as.Date(unique(call.list$timestamp))))`
**Questions asked:** `r d.print[d$discord.name == x.discord.name, "prev.questions"]`
**Missing in class days:** `r d.print[d$unique.name == id, "missing.in.class.days"]` (base grade lowered by 0.2 per day)
**Days Absent:** `r d.print[d.print$discord.name == x.discord.name, "days.absent"]` / `r case.sessions`
**List of questions:**
```{r echo=FALSE}
call.list[call.list$unique.name == id,]
call.list[call.list$discord.name == x.discord.name,]
```
**Luckiness:** `r d.print[d.print$discord.name == x.discord.name, "prop.asked.quant"]`
If you a student has a luckiness over 50% that means that they were helped by the weighting of the system and/or got lucky. We did not penalize *any* students with a luckiness under 50% for absences.

View File

@@ -1,5 +1,5 @@
myuw <- read.csv("data/2024_autumn_COM_481_A_students.csv")
gs <- read.delim("data/student_information.tsv")
myuw <- read.csv("myuw-COMMLD_570_A_spring_2021_students.csv")
gs <- read.delim("student_information.tsv")
## these are students who dropped the class (should be empty)
gs[!gs$Your.UW.student.number %in% myuw$StudentNo,]
@@ -7,12 +7,6 @@ gs[!gs$Your.UW.student.number %in% myuw$StudentNo,]
## these are students who are in the class but didn't reply to the form
myuw[!myuw$StudentNo %in% gs$Your.UW.student.number,]
roster.merged <- merge(myuw, gs, by.x="StudentNo", by.y="Your.UW.student.number", all.x=TRUE, all.y=FALSE)
roster.merged[,c("StudentNo", "Email", "FirstName", "LastName", "Your.username.on.the.class.Discord.server", "checked.off.on.discord")][!roster.merged$StudentNo %in% gs$Your.UW.student.number,]
## these are students who are in the class but didn't reply to the form
## read all the folks who have been called and see who is missing from
## the google sheet

View File

@@ -1,129 +1,25 @@
setwd("data/")
setwd("~/online_communities/coldcallbot/data/")
library(ggplot2)
library(data.table)
################################################
## LOAD call_list TSV data
################################################
gs <- read.delim("student_information.tsv")
d <- gs[,c(2,4)]
colnames(d) <- c("student.num", "unique.name")
call.list <- do.call("rbind", lapply(list.files(".", pattern="^call_list-.*tsv$"), function (x) {read.delim(x, stringsAsFactors=FALSE)[,1:5]}))
call.list <- do.call("rbind", lapply(list.files(".", pattern="^call_list-.*tsv$"), function (x) {read.delim(x, stringsAsFactors=FALSE)[,1:4]}))
colnames(call.list) <- gsub("_", ".", colnames(call.list))
colnames(call.list)[1] <- "unique.name"
colnames(call.list)[2] <- "preferred.name"
table(call.list$unique.name[call.list$answered])
table(call.list$unique_name[call.list$answered])
## drop calls where the person wasn't present
call.list.full <- call.list
call.list[!call.list$answered,]
call.list <- call.list[call.list$answered,]
## show the distribution of assessments
prop.table(table(call.list$assessment))
call.counts <- data.frame(table(call.list$unique.name))
colnames(call.counts) <- c("unique.name", "num.calls")
## create list of folks who are missing in class w/o reporting it
absence.data.cols <- c("unique.name", "date.absent", "reported")
missing.in.class <- call.list.full[!call.list.full$answered,
c("unique.name", "timestamp")]
missing.in.class$date.absent <- as.Date(missing.in.class$timestamp)
missing.in.class$reported <- rep(FALSE, nrow(missing.in.class))
missing.in.class <- missing.in.class[,absence.data.cols]
missing.in.class <- unique(missing.in.class)
################################################
## LOAD absence data TSV data
################################################
absence.google <- read.delim("optout_poll_data.tsv")
colnames(absence.google) <- c("timestamp", "unique.name", "date.absent")
absence.google$date.absent <- as.Date(absence.google$date.absent, format="%m/%d/%Y")
absence.google$reported <- TRUE
absence.google <- absence.google[,absence.data.cols]
absence.google <- unique(absence.google)
## combine the two absence lists and then create a unique subset
absence <- rbind(missing.in.class[,absence.data.cols],
absence.google[,absence.data.cols])
## these are people that show up in both lists (i.e., probably they
## submitted too late but it's worth verifying before we penalize
## them. i'd actually remove them from the absence sheet to suppress
## this error
absence[duplicated(absence[,1:2]),]
absence <- absence[!duplicated(absence[,1:2]),]
## print total questions asked and absences
absence.count <- data.frame(table(unique(absence[,c("unique.name", "date.absent")])[,"unique.name"]))
colnames(absence.count) <- c("unique.name", "absences")
## load up the full class list
gs <- read.delim("student_information.tsv")
d <- gs[,c("Your.UW.student.number", "Name.you.d.like.to.go.by.in.class")]
colnames(d) <- c("unique.name", "short.name")
## merge in the call counts
d <- merge(d, call.counts, all.x=TRUE, all.y=FALSE, by="unique.name")
d <- merge(d, absence.count, by="unique.name", all.x=TRUE, all.y=FALSE)
d
## set anything that's missing to zero
d$num.calls[is.na(d$num.calls)] <- 0
d$absences[is.na(d$absences)] <- 0
################################################
## list people who have been absent often or called on a lot
################################################
## list students sorted in terms of (a) absences and (b) prev questions
d[sort.list(d$absences),]
d[sort.list(d$num.calls, decreasing=TRUE),]
################################################
## build visualizations
################################################
library(ggplot2)
color.gradient <- scales::seq_gradient_pal("yellow", "magenta", "Lab")(seq(0,1,length.out=range(d$absences)[2]+1))
table(d$num.calls, d$absences)
png("questions_absence_histogram_combined.png", units="px", width=600, height=400)
ggplot(d) +
aes(x=as.factor(num.calls), fill=as.factor(absences)) +
geom_bar(color="black") +
stat_count() +
scale_x_discrete("Number of questions answered") +
scale_y_continuous("Number of students") +
##scale_fill_brewer("Absences", palette="Blues") +
scale_fill_manual("Opt-outs", values=color.gradient) +
theme_bw()
dev.off()
absence.labeller <- function (df) {
lapply(df, function (x) { paste("Absences:", x) })
}
## png("questions_absence_histogram_facets.png", units="px", width=600, height=400)
## ggplot(d) +
## aes(x=as.factor(num.calls)) +
## geom_bar() +
## stat_count() +
## scale_x_discrete("Number of questions answered") +
## scale_y_continuous("Number of students") +
## theme_bw() +
## facet_wrap(.~absences, ncol=5, labeller="absence.labeller")
d <- merge(d, call.counts, all.x=TRUE, all.y=TRUE, by="unique.name"); d

View File

@@ -8,61 +8,51 @@ from csv import DictReader
import os.path
import re
import json
class ColdCall():
def __init__ (self, record_attendance=True):
with open("configuration.json") as config_file:
config = json.loads(config_file.read())
self.today = str(datetime.date(datetime.now()))
# how much less likely should it be that a student is called upon?
self.weight = 2
self.record_attendance = record_attendance
# filenames
self.__fn_studentinfo = config["student_info_file"]
self.__fn_daily_calllist = config["daily_calllist_file"].format(date=self.today)
self.__fn_daily_attendance = config["daily_attendance"].format(date=self.today)
self.__fn_studentinfo = "data/student_information.tsv"
self.__fn_daily_calllist = f"data/call_list-{self.today}.tsv"
self.__fn_daily_attendance = f"data/attendance-{self.today}.tsv"
self.unique_row = config["unique_name_rowname"]
if "preferred_name_rowname" in config:
self.preferred_row = config["preferred_name_rowname"]
else:
self.preferred_row = None
self.preferred_names = self.__get_preferred_names()
def __load_prev_questions(self):
previous_questions = defaultdict(int)
for fn in listdir("./data/"):
if re.match(r"call_list-\d{4}-\d{2}-\d{2}.tsv", fn):
if re.match("call_list-\d{4}-\d{2}-\d{2}.tsv", fn):
with open(f"./data/{fn}", 'r') as f:
for row in DictReader(f, delimiter="\t"):
if not row["answered"] == "FALSE":
previous_questions[row[self.unique_row]] += 1
previous_questions[row["unique_name"]] += 1
return previous_questions
def get_preferred_names(self):
def __get_preferred_names(self):
# translate the unique name into the preferred students name,
# if possible, otherwise return the unique name
preferred_names = {}
with open(self.__fn_studentinfo, 'r') as f:
for row in DictReader(f, delimiter="\t"):
preferred_names[row[self.unique_row]] = row[self.preferred_row]
preferred_names[row["Your username on the class Teams server"]] = row["Name you'd like to go by in class"]
return(preferred_names)
def __get_preferred_name(self, selected_student):
preferred_names = self.get_preferred_names()
if selected_student in preferred_names:
return preferred_names[selected_student]
if selected_student in self.preferred_names:
return self.preferred_names[selected_student]
else:
return None
def select_student_from_list(self, students_present):
def __select_student_from_list (self, students_present):
prev_questions = self.__load_prev_questions()
# created a weighted list by starting out with everybody 1
@@ -77,7 +67,7 @@ class ColdCall():
# print(weights) # DEBUG LINE
return choices(list(weights.keys()), weights=list(weights.values()), k=1)[0]
def record_attendance(self, students_present):
def __record_attendance(self, students_present):
# if it's the first one of the day, write it out
if not os.path.exists(self.__fn_daily_attendance):
with open(self.__fn_daily_attendance, "w") as f:
@@ -89,28 +79,24 @@ class ColdCall():
",".join(students_present)]),
file=f)
def record_coldcall(self, selected_student):
def __record_coldcall(self, selected_student):
# if it's the first one of the day, write it out
if not os.path.exists(self.__fn_daily_calllist):
with open(self.__fn_daily_calllist, "w") as f:
print("\t".join([self.unique_row, self.preferred_row, "answered", "assessment", "timestamp"]), file=f)
preferred_name = self.__get_preferred_name(selected_student)
if preferred_name == None:
preferred_name = ""
print("\t".join(["unique_name", "timestamp", "answered", "assessment"]), file=f)
# open for appending the student
with open(self.__fn_daily_calllist, "a") as f:
print("\t".join([selected_student, preferred_name,
"MISSING", "MISSING", str(datetime.now())]), file=f)
print("\t".join([selected_student, str(datetime.now()),
"MISSING", "MISSING"]), file=f)
def coldcall(self, students_present):
selected_student = self.select_student_from_list(students_present)
selected_student = self.__select_student_from_list(students_present)
# record the called-upon student in the right place
if self.record_attendance:
self.record_attendance(students_present)
self.record_coldcall(selected_student)
self.__record_attendance(students_present)
self.__record_coldcall(selected_student)
preferred_name = self.__get_preferred_name(selected_student)
if preferred_name:
@@ -119,3 +105,13 @@ class ColdCall():
coldcall_message = f"@{selected_student}, you're up!"
return coldcall_message
# cc = ColdCall()
# test_student_list = ["jordan", "Kristen Larrick", "Madison Heisterman", "Maria.Au20", "Laura (Alia) Levi", "Leona Aklipi", "anne", "emmaaitelli", "ashleylee", "allie_partridge", "Tiana_Cole", "Hamin", "Ella Qu", "Shizuka", "Ben Baird", "Kim Do", "Isaacm24", "Sam Bell", "Courtneylg"]
# print(cc.coldcall(test_student_list))
# test_student_list = ["jordan", "Kristen Larrick", "Mako"]
# print(cc.coldcall(test_student_list))
# test_student_list = ["jordan", "Kristen Larrick"]
# print(cc.coldcall(test_student_list))

View File

@@ -1,94 +1,15 @@
#!/usr/bin/env python3
from coldcall import ColdCall
from datetime import datetime
from csv import DictReader
from random import sample
import json
import argparse
parser = argparse.ArgumentParser(description='run the coldcall bot manually to create a coldcall list')
parser.add_argument('-n', '--num', dest="num_calls", default=100, const=100, type=int, nargs='?',
help="how many students should be called")
parser.add_argument('-s', '--shuffle', dest="shuffle_roster", action="store_true",
help="select without replacement (i.e., call each person once with n equal to the group size)")
args = parser.parse_args()
current_time = datetime.today()
with open("configuration.json") as config_file:
config = json.loads(config_file.read())
import re
## create the coldcall object
cc = ColdCall(record_attendance=False)
def get_missing(d=current_time):
date_string = f'{d.month}/{d.day}/{d.year}'
with open(config["optout_file"], 'r') as f:
for row in DictReader(f, delimiter="\t"):
if row["Date of class session you will be absent"] == date_string:
yield(row[config["unique_name_rowname"]])
student_list = cc.preferred_names
full_names = {}
registered_students = []
with open(config["roster_file"], 'r') as f:
for row in DictReader(f, delimiter=","):
student_no = row["StudentNo"].strip()
registered_students.append(student_no)
full_names[student_no] = f"{row[config['roster_firstname_rowname']]} {row[config['roster_lastname_rowname']]}"
# print("Registered:", registered_students) # useful for debug
# print out 100 students
# get pronouns
with open(config["student_info_file"], 'r') as f:
preferred_pronouns = {}
for row in DictReader(f, delimiter="\t"):
preferred_pronouns[row[config["unique_name_rowname"]]] = row["Preferred pronouns"]
# print(preferred_pronouns)
for i in range(100):
print(f"{i + 1}. {cc.coldcall(student_list)} [ ] [ ]\n")
missing_today = [x for x in get_missing(current_time)]
# print("Missing Today: ", missing_today) # useful for debug
preferred_names = cc.get_preferred_names()
# print("Preferred names:", preferred_names) # useful for debug
students_present = [s for s in registered_students if s not in missing_today]
# print("Students present:", students_present) # useful for debug
def print_selected(selected_student):
if "print_index" in globals():
global print_index
else:
global print_index
print_index = 1
try:
preferred_name = preferred_names[selected_student]
except KeyError:
preferred_name = "[unknown preferred name]"
if selected_student in preferred_pronouns:
pronouns = preferred_pronouns[selected_student]
else:
pronouns = "[unknown pronouns]"
print(f"{print_index}. {preferred_name} :: {pronouns} :: {full_names[selected_student]} :: {selected_student}")
cc.record_coldcall(selected_student)
print_index += 1 ## increase the index
# if we're in suffle mode
shuffle = args.shuffle_roster
print_index = 1
if shuffle:
for selected_student in sample(students_present, len(students_present)):
print_selected(selected_student)
else:
num_calls = args.num_calls
for i in range(num_calls):
selected_student = cc.select_student_from_list(students_present)
print_selected(selected_student)

View File

@@ -1 +0,0 @@
data/configuration.json

View File

@@ -1,16 +0,0 @@
{
"roster_file" : "data/FIXME.csv",
"roster_unique_rowname" : "StudentNo",
"roster_firstname_rowname" : "FirstName",
"roster_lastname_rowname" : "LastName",
"student_info_file" : "data/student_information.tsv",
"student_info_gsheet_id" : "FIXME",
"student_info_gsheet_gid" : 99999999,
"optout_file" : "data/optout_poll_data.tsv",
"optout_gsheet_id" : "FIXME",
"optout_gsheet_gid" : 99999999,
"daily_calllist_file" : "data/call_list-{date}.tsv",
"daily_attendance" : "data/attendance-{date}.tsv",
"unique_name_rowname" : "Your UW student number",
"preferred_name_rowname" : "Name you'd like to go by in class"
}

4
data/download_data.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
wget 'https://docs.google.com/spreadsheets/d/FIXME/export?gid=FIXME&format=tsv' -O 'student_information.tsv'

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env python3
import json
import subprocess
with open("configuration.json", 'r') as config_file:
config = json.loads(config_file.read())
base_url = 'https://docs.google.com/spreadsheets/d/{id}/export?gid={gid}&format=tsv'
student_info_url = base_url.format(id=config["student_info_gsheet_id"], gid=config["student_info_gsheet_gid"])
subprocess.run(["wget", student_info_url, "-O", config["student_info_file"]], check=True)
optout_url = base_url.format(id=config["optout_gsheet_id"], gid=config["optout_gsheet_gid"])
subprocess.run(["wget", optout_url, "-O", config["optout_file"]], check=True)

155
flask_app/app.py Normal file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python
import pandas as pd
from random import choices, shuffle
from datetime import datetime
import csv
import os
from flask import Flask, render_template, request, abort
app = Flask(__name__)
@app.route("/")
def hello_world():
return "<p>Hello, Test!</p>"
@app.route('/response_quality', methods=['POST'])
def response_quality():
student_name = request.form['studentName']
button_value = request.form['buttonValue']
course = request.form['course']
print(button_value)
fn = f'../assessments/{course}/{course}.csv'
if button_value == 'absent':
answered = 'F'
button_value = ''
else:
answered = 'T'
if button_value != 'get_next':
write_to_file(student_name, fn,
answered=answered,
assessment=button_value)
student = coldcall_student(course)
print(f'Sending {student}')
return student
@app.route("/coldcaller/<course>", methods=['POST','GET'])
def coldcaller(course):
public = request.args.get('public')
print(public)
if request.method == "POST":
student = coldcall_student(course)
if not student:
abort(404)
else:
student = ''
return render_template('cold_caller.html', student=student, public=public)
def coldcall_student(course):
if course not in ["com_304","com_411","com_674", "amap"]:
return None
weight = 2
students = pd.read_csv(f'../assessments/{course}/{course}_students.csv').Name
out_fn = f'../assessments/{course}/{course}.csv'
caller = Caller(out_fn, students, weight)
student = caller.get_random_student()
return student
@app.route("/shuffler", methods=['POST','GET'])
def shuffler():
course = request.args.get('course')
try:
student_list = pd.read_csv(f'../assessments/{course}/{course}_students.csv').Name
except FileNotFoundError:
abort(404)
shuffle(student_list)
print(student_list)
return render_template('shuffler.html', result=student_list)
@app.route("/make_groups", methods=['POST','GET'])
def make_groups():
course = request.args.get('course')
group_size = int(request.args.get('group_size'))
print('running')
try:
student_list = pd.read_csv(f'../assessments/{course}/{course}_students.csv').Name
except FileNotFoundError:
abort(404)
shuffle(student_list)
print(student_list)
print(range(0,len(student_list)//group_size + 1, group_size))
result = []
j = 1
for i in range(0,len(student_list), group_size):
result.append((j, student_list[i:i+group_size]))
j += 1
return render_template('group_maker.html', result=result)
class Caller:
def __init__(self, out_fn, students, weight = 2):
self.weight = weight
self.fn = out_fn
self.students = students
self.last_chosen = None
self.today = datetime.now().date()
self.weights_dict = self.get_weights()
def get_weights(self):
times_called = self.get_times_called()
weights_dict = {}
for student in self.students:
try:
curr_tc = times_called[student]
except KeyError:
curr_tc = 0
student_weight = (1/self.weight) ** curr_tc
weights_dict[student] = student_weight
return weights_dict
def get_times_called(self):
try:
df = pd.read_csv(self.fn)
if len(df) > 0:
self.last_chosen = df.name.iloc[-1]
df.date = pd.to_datetime(df.date).dt.date
times_called = df[(df.answered.isin(['T','TRUE']))|(df.date==self.today)].groupby('name').size()
self.absent_today = df.loc[(df.date==self.today) & (df.answered.isin(['F', 'FALSE'])), 'name']
except FileNotFoundError or IndexError:
times_called = pd.DataFrame()
self.absent_today = pd.DataFrame()
return times_called
def update_weight(self, student):
self.weights_dict[student] /= self.weight
def get_random_student(self, can_repeat=False):
if not can_repeat:
curr_weights = {k:v for k,v in self.weights_dict.items() if k != self.last_chosen}
else:
curr_weights = self.weights_dict
for student in set(self.absent_today):
if student != self.last_chosen:
del curr_weights[student]
rand_student = choices(list(curr_weights.keys()), weights=list(curr_weights.values()), k=1)[0]
print(f"Weight of {rand_student}: {curr_weights[rand_student]}")
self.update_weight(rand_student)
return(rand_student)
def write_to_file(student, fn, answered, assessment):
if not os.path.exists(fn):
with open(fn, 'w') as f:
f.write(','.join(['name', 'date', 'answered', 'assessment']))
f.write('\n')
with open(fn, 'a') as f:
out_csv = csv.writer(f)
out_csv.writerow([student,datetime.now().date(),answered,assessment])

25
flask_app/static/main.css Normal file
View File

@@ -0,0 +1,25 @@
body {
background: Linen;
margin-top: 50px;
margin-left: 100px;
margin-right: 100px;
font-family: Georgia, serif;
color: DarkSlateGray;
font-size: 1.3em;
}
p {
font-family: Georgia, serif;
font-size: 1em;
color: DarkSlateGray;
}
h1 {
font-family: Verdana, Geneva, sans-serif;
font-size: 2.5em;
color: FireBrick;
}
.rand-button {
font-size: .8em;
}

View File

@@ -0,0 +1,23 @@
$(document).ready(function() {
$('#goodButton, #badButton, #neutralButton, #absentButton, #nextButton').on('click', function() {
var studentName = $('#studentName').text();
console.log(studentName);
var buttonValue = $(this).val();
var courseCode = window.location.pathname.split('/').pop();
$.ajax({
url: '/response_quality',
type: 'POST',
data: {
studentName: studentName,
buttonValue: buttonValue,
course: courseCode
},
success: function(response) {
$('#studentName').text(response);
},
error: function(error) {
console.log(error);
}
});
});
});

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<title>Random Student Picker</title>
<link rel="stylesheet" href='/static/main.css' />
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="{{ url_for('static', filename='process_button.js') }}"></script>
</head>
<body>
<h3>
The next student is:
</h3>
<h2 id='studentName' name='studentName'>{{student}}</h2>
{% if student != '' %}
{% if public is none %}
<button class='assessment' id="goodButton" value="G">Good</button>
<button class='assessment' id="badButton" value="B">Bad</button>
<button class='assessment' id="neutralButton" value="M">Neutral</button>
<button class='assessment' id="absentButton" value="absent">Absent</button>
<button class='assessment' id="nextButton" value="get_next">Skip</button>
{% else %}
<button class='assessment' id="neutralButton" value="?">Next Student</button>
<button class='assessment' id="absentButton" value="absent">Absent</button>
{% endif %}
{% else %}
<form method="post" id="todo-form">
<button class='rand-button' type="submit">Get random student</button>
</form>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<title>Random Student Picker</title>
<link rel="stylesheet" href='/static/main.css' />
</head>
<body>
<h3>
Groups:
</h3>
{% for group in result %}
<h2>Group {{group[0]}}</h2>
<ul>
{% for member in group[1] %}
<li> {{member}} </li>
{% endfor %}
</ul>
{% endfor %}
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Shuffled List</title>
<link rel="stylesheet" href='/static/main.css' />
</head>
<body>
<h3>
Shuffled List:
</h3>
<ul>
{% for member in result %}
<li> {{member}} </li>
{% endfor %}
</ul>
</body>
</html>

71
flask_app/test.csv Normal file
View File

@@ -0,0 +1,71 @@
name,date,answered,assessment
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
owen,2022-01-01,,
dad,2022-01-01,,
dad,2022-01-01,,
1 name date answered assessment
2 owen 2022-01-01
3 dad 2022-01-01
4 owen 2022-01-01
5 owen 2022-01-01
6 dad 2022-01-01
7 owen 2022-01-01
8 dad 2022-01-01
9 dad 2022-01-01
10 dad 2022-01-01
11 owen 2022-01-01
12 owen 2022-01-01
13 dad 2022-01-01
14 dad 2022-01-01
15 dad 2022-01-01
16 dad 2022-01-01
17 owen 2022-01-01
18 owen 2022-01-01
19 owen 2022-01-01
20 owen 2022-01-01
21 owen 2022-01-01
22 dad 2022-01-01
23 dad 2022-01-01
24 dad 2022-01-01
25 dad 2022-01-01
26 owen 2022-01-01
27 owen 2022-01-01
28 dad 2022-01-01
29 owen 2022-01-01
30 dad 2022-01-01
31 dad 2022-01-01
32 owen 2022-01-01
33 owen 2022-01-01
34 dad 2022-01-01
35 dad 2022-01-01
36 owen 2022-01-01
37 owen 2022-01-01
38 dad 2022-01-01
39 owen 2022-01-01
40 owen 2022-01-01
41 dad 2022-01-01
42 owen 2022-01-01
43 dad 2022-01-01
44 owen 2022-01-01
45 dad 2022-01-01
46 owen 2022-01-01
47 owen 2022-01-01
48 dad 2022-01-01
49 dad 2022-01-01
50 dad 2022-01-01
51 owen 2022-01-01
52 dad 2022-01-01
53 dad 2022-01-01
54 owen 2022-01-01
55 owen 2022-01-01
56 dad 2022-01-01
57 owen 2022-01-01
58 owen 2022-01-01
59 dad 2022-01-01
60 dad 2022-01-01
61 dad 2022-01-01
62 dad 2022-01-01
63 owen 2022-01-01
64 owen 2022-01-01
65 owen 2022-01-01
66 owen 2022-01-01
67 dad 2022-01-01
68 dad 2022-01-01
69 owen 2022-01-01
70 dad 2022-01-01
71 dad 2022-01-01