18
0

13 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
92e65de771 Merge branch 'master' of code.communitydata.science:coldcallbot-discord 2022-01-05 13:52:27 +09:00
cda1cb3b3c remove references to boy (should be bot, obvs) 2022-01-05 13:51:02 +09:00
3bd4c9c2a6 updated grade code
- COMMLD570A did not penalize/track absences at all so i cut this completely
2022-01-05 11:43:41 +09:00
da8f47aa27 report on the number of questions answered, not just asked 2022-01-05 11:43:41 +09:00
95a9977572 small changes to get track participation working 2022-01-05 11:43:41 +09:00
f590bf88bc start list at 1 instead of 0 2022-01-05 11:43:41 +09:00
914f5973c3 renamed manual coldcall bot script
it's a /bot/, not a boy
2022-01-05 11:43:41 +09:00
d8e662b5c3 reworking the script to work with COMMLD570A (editted)
this commit was editted to not include student details

- new manual version of the coldcallbot
- minor tweaks to make things less discord specific
- changes for new URLs, filenames, and such
2022-01-05 11:27:59 +09:00
15 changed files with 445 additions and 191 deletions

1
.gitignore vendored
View File

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

4
README
View File

@@ -1,7 +1,7 @@
Setting up the Discord Bot
======================================
I run the Discord boy from my laptop. It requires the discord Python
I run the Discord bot from my laptop. It requires the discord Python
module available in PyPi and installable like:
$ pip3 install discord
@@ -44,7 +44,7 @@ Daily Process
You need to start the bot from the laptop each day. I do that by:
$ ./coldcallboy.py
$ ./coldcallbot.py
The bot will run in the terminal, print out data as it works including
detailed weights as it goes, and it will record data into files in the

View File

@@ -1,36 +1,23 @@
## load in the data
#################################
case.sessions <- 15
myuw <- read.csv("myuw-COM_482_A_autumn_2020_students.csv", stringsAsFactors=FALSE)
myuw <- read.csv("myuw-COMMLD_570_A_spring_2021_students.csv", stringsAsFactors=FALSE)
## class-level variables
question.grades <- c("GOOD"=100, "FAIR"=100-(50/3.3), "BAD"=100-(50/(3.3)*2))
missed.question.penalty <- (50/3.3) * 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))
source("../assessment_and_tracking/track_participation.R")
setwd("case_grades")
rownames(d) <- d$discord.name
rownames(d) <- d$unique.name
## show the distribution of assessments
table(call.list.full$assessment)
prop.table(table(call.list.full$assessment))
table(call.list.full$answered)
prop.table(table(call.list.full$answered))
table(call.list$assessment)
prop.table(table(call.list$assessment))
table(call.list$answered)
prop.table(table(call.list$answered))
total.questions.asked <- nrow(call.list.full)
## create new column with number of questions present
d$prop.asked <- d$num.calls / d$num.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)]
## this is generating broken stuff but it's not used for anything
d$prop.asked.quant <- cut(d$prop.asked, breaks=prop.asks.quantiles,
labels=names(prop.asks.quantiles)[1:(length(prop.asks.quantiles)-1)])
total.questions.asked <- nrow(call.list)
## generate grades
##########################################################
@@ -39,81 +26,47 @@ 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[d$days.absent < 0.25*case.sessions])
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.discord.name) {
q.scores <- question.grades[call.list$assessment[call.list$discord.name == x.discord.name]]
gen.part.grade <- function (x.unique.name) {
q.scores <- question.grades[call.list$assessment[call.list$unique.name == x.unique.name]]
base.score <- mean(q.scores, na.rm=TRUE)
## number of missing days
missing.days <- nrow(missing.in.class[missing.in.class$discord.name == x.discord.name,])
# missing.days <- nrow(missing.in.class[missing.in.class$unique.name == x.unique.name,])
## return the final score
data.frame(discord.name=x.discord.name,
part.grade=(base.score - missing.days * missed.question.penalty))
data.frame(unique.name=x.unique.name,
part.grade=(base.score))
}
tmp <- do.call("rbind", lapply(d$discord.name[d$num.calls >= questions.cutoff], gen.part.grade))
d[as.character(tmp$discord.name), "part.grade"] <- tmp$part.grade
tmp <- do.call("rbind", lapply(d$unique.name, gen.part.grade))
## next handle the folks *under* the median
## 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,]
## set those people to 0 :(
d$part.grade[d$num.calls == 0] <- 0
## step 2 is to handle folks who got unlucky in the normal way
tmp <- do.call("rbind", lapply(d$discord.name[is.na(d$part.grade) & d$prop.asked <= median(d$prop.asked)], gen.part.grade))
d[as.character(tmp$discord.name), "part.grade"] <- tmp$part.grade
## the people who are left are lucky and still undercounted so we'll penalize them
d[is.na(d$part.grade),]
penalized.discord.names <- d$discord.name[is.na(d$part.grade)]
d[as.character(tmp$unique.name), "part.grade"] <- tmp$part.grade
## generate the baseline participation grades as per the process above
tmp <- do.call("rbind", lapply(penalized.discord.names, gen.part.grade))
d[as.character(tmp$discord.name), "part.grade"] <- tmp$part.grade
## now add "zeros" for every questions that is below the normal
d[as.character(penalized.discord.names),"part.grade"] <- ((
(questions.cutoff - d[as.character(penalized.discord.names),"num.calls"] * 0) +
(d[as.character(penalized.discord.names),"num.calls"] * d[as.character(penalized.discord.names),"part.grade"]) )
/ questions.cutoff)
d[as.character(penalized.discord.names),]
## map part grades back to 4.0 letter scale and points
d$part.4point <-round((d$part.grade / (50/3.3)) - 2.6, 2)
d[sort.list(d$prop.asked), c("discord.name", "num.calls", "num.present",
"prop.asked", "prop.asked.quant", "part.grade", "part.4point",
"days.absent")]
d[sort.list(d$part.4point), c("discord.name", "num.calls", "num.present",
"prop.asked", "prop.asked.quant", "part.grade", "part.4point",
"days.absent")]
d[sort.list(d$part.4point),]
## writing out data
quantile(d$num.calls, probs=(0:100*0.01))
d.print <- merge(d, myuw[,c("StudentNo", "FirstName", "LastName", "UWNetID")],
by.x="student.num", by.y="StudentNo")
write.csv(d.print, file="final_participation_grades.csv")
library(rmarkdown)
## library(rmarkdown)
for (x.discord.name in d$discord.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$discord.name == x.discord.name],
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,4 +1,4 @@
myuw <- read.csv("myuw-COM_482_A_autumn_2020_students.csv")
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)
@@ -10,14 +10,14 @@ myuw[!myuw$StudentNo %in% gs$Your.UW.student.number,]
## read all the folks who have been called and see who is missing from
## the google sheet
call.list <- unlist(lapply(list.files(".", pattern="^attendance-.*tsv$"), function (x) {
d <- read.delim(x)
strsplit(d[[2]], ",")
})
)
present <- unique(call.list)
present[!present %in% gs[["Your.username.on.the.class.Discord.server"]]]
## call.list <- unlist(lapply(list.files(".", pattern="^attendance-.*tsv$"), function (x) {
## d <- read.delim(x)
## strsplit(d[[2]], ",")
## })
## )
## present <- unique(call.list)
## present[!present %in% gs[["Your.username.on.the.class.Discord.server"]]]
## and never attended class..
gs[["Your.username.on.the.class.Discord.server"]][!gs[["Your.username.on.the.class.Discord.server"]] %in% present]
## gs[["Your.username.on.the.class.Discord.server"]][!gs[["Your.username.on.the.class.Discord.server"]] %in% present]

View File

@@ -1,109 +1,25 @@
setwd("~/online_communities/coldcallbot/data/")
library(ggplot2)
library(data.table)
gs <- read.delim("student_information.tsv")
d <- gs[,c(2,5)]
colnames(d) <- c("student.num", "discord.name")
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:4]}))
call.list <- do.call("rbind", lapply(list.files(".", pattern="^call_list-.*tsv$"), function (x) {read.delim(x)[,1:4]}))
colnames(call.list) <- gsub("_", ".", colnames(call.list))
call.list$day <- as.Date(call.list$timestamp)
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,]
call.counts <- data.frame(table(call.list$discord.name))
colnames(call.counts) <- c("discord.name", "num.calls")
call.counts <- data.frame(table(call.list$unique.name))
colnames(call.counts) <- c("unique.name", "num.calls")
d <- merge(d, call.counts, all.x=TRUE, all.y=TRUE, by="discord.name"); d
## set anything that's missing to zero
d$num.calls[is.na(d$num.calls)] <- 0
attendance <- unlist(lapply(list.files(".", pattern="^attendance-.*tsv$"), function (x) {d <- read.delim(x); strsplit(d[[2]], ",")}))
file.to.attendance.list <- function (x) {
tmp <- read.delim(x)
d.out <- data.frame(discord.name=unlist(strsplit(tmp[[2]], ",")))
d.out$day <- rep(as.Date(tmp[[1]][1]), nrow(d.out))
return(d.out)
}
attendance <- do.call("rbind",
lapply(list.files(".", pattern="^attendance-.*tsv$"),
file.to.attendance.list))
## create list of folks who are missing in class
missing.in.class <- call.list.full[is.na(call.list.full$answered) |
(!is.na(call.list.full$answered) & !call.list.full$answered),
c("discord.name", "day")]
missing.in.class <- unique(missing.in.class)
setDT(attendance)
setkey(attendance, discord.name, day)
setDT(missing.in.class)
setkey(missing.in.class, discord.name, day)
## drop presence for people on missing days
attendance[missing.in.class,]
attendance <- as.data.frame(attendance[!missing.in.class,])
attendance.counts <- data.frame(table(attendance$discord.name))
colnames(attendance.counts) <- c("discord.name", "num.present")
d <- merge(d, attendance.counts,
all.x=TRUE, all.y=TRUE,
by="discord.name")
days.list <- lapply(unique(attendance$day), function (day) {
day.total <- table(call.list.full$day == day)[["TRUE"]]
lapply(d$discord.name, function (discord.name) {
num.present <- nrow(attendance[attendance$day == day & attendance$discord.name == discord.name,])
if (num.present/day.total > 1) {print(day)}
data.frame(discord.name=discord.name,
days.present=(num.present/day.total))
})
})
days.tmp <- do.call("rbind", lapply(days.list, function (x) do.call("rbind", x)))
days.tbl <- tapply(days.tmp$days.present, days.tmp$discord.name, sum)
attendance.days <- data.frame(discord.name=names(days.tbl),
days.present=days.tbl,
days.absent=length(list.files(".", pattern="^attendance-.*tsv$"))-days.tbl)
d <- merge(d, attendance.days,
all.x=TRUE, all.y=TRUE, by="discord.name")
d[sort.list(d$days.absent), c("discord.name", "num.calls", "days.absent")]
## make some visualizations of whose here/not here
#######################################################
png("questions_absence_histogram_combined.png", units="px", width=800, height=600)
ggplot(d) +
aes(x=as.factor(num.calls), fill=days.absent, group=days.absent) +
geom_bar(color="black") +
scale_x_discrete("Number of questions asked") +
scale_y_continuous("Number of students") +
scale_fill_continuous("Days absent", low="red", high="blue")+
theme_bw()
dev.off()
png("questions_absenses_boxplots.png", units="px", width=800, height=600)
ggplot(data=d) +
aes(x=as.factor(num.calls), y=days.absent) +
geom_boxplot() +
scale_x_discrete("Number of questions asked") +
scale_y_continuous("Days absent")
dev.off()
d <- merge(d, call.counts, all.x=TRUE, all.y=TRUE, by="unique.name"); d

View File

@@ -8,19 +8,21 @@ from csv import DictReader
import os.path
import re
import discord
class ColdCall():
def __init__ (self):
def __init__ (self, record_attendance=True):
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 = "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.preferred_names = self.__get_preferred_names()
def __load_prev_questions(self):
previous_questions = defaultdict(int)
@@ -29,21 +31,24 @@ class ColdCall():
with open(f"./data/{fn}", 'r') as f:
for row in DictReader(f, delimiter="\t"):
if not row["answered"] == "FALSE":
previous_questions[row["discord_name"]] += 1
previous_questions[row["unique_name"]] += 1
return previous_questions
def __get_preferred_name(self, selected_student):
# translate the discord name into the preferred students name,
# if possible, otherwise return the discord name
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["Your username on the class Discord server"]] = row["Name you'd like to go by in class"]
preferred_names[row["Your username on the class Teams server"]] = row["Name you'd like to go by in class"]
if selected_student in preferred_names:
return preferred_names[selected_student]
return(preferred_names)
def __get_preferred_name(self, selected_student):
if selected_student in self.preferred_names:
return self.preferred_names[selected_student]
else:
return None
@@ -59,7 +64,7 @@ class ColdCall():
weights[s] = weights[s] / self.weight
# choose one student from the weighted list
print(weights)
# print(weights) # DEBUG LINE
return choices(list(weights.keys()), weights=list(weights.values()), k=1)[0]
def __record_attendance(self, students_present):
@@ -78,7 +83,7 @@ class ColdCall():
# 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(["discord_name", "timestamp", "answered", "assessment"]), file=f)
print("\t".join(["unique_name", "timestamp", "answered", "assessment"]), file=f)
# open for appending the student
with open(self.__fn_daily_calllist, "a") as f:
@@ -89,6 +94,7 @@ class ColdCall():
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)

15
coldcallbot-manual.py Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from coldcall import ColdCall
import re
## create the coldcall object
cc = ColdCall(record_attendance=False)
student_list = cc.preferred_names
# print out 100 students
for i in range(100):
print(f"{i + 1}. {cc.coldcall(student_list)} [ ] [ ]\n")

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'

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