Commit c8289cf3 authored by Alan Marchiori's avatar Alan Marchiori
Browse files

added email command

parent 3765049c
......@@ -5,4 +5,5 @@ from .test import test
from .grade import grade
from .report import report
from .menu import menu
__all__ = ['userconfig', 'init', 'check', 'test', 'grade', 'report' ,'menu']
from .email import email
__all__ = ['userconfig', 'init', 'check', 'test', 'grade', 'report' ,'menu', 'email']
......@@ -73,7 +73,7 @@ def show_report(hist, partstr, info):
@click.command(short_help="Check LAB")
@click.option('--lab', type=int, default=None)
@click.option('--part', type=int, default=None)
def check(lab, part):
def check(lab, part, **kwargs):
"""check LAB and PART without submitting. If LAB is
omitted, we will attempt to auto detect from the working directory. If PART
is omitted, ALL parts are checked.
......@@ -108,6 +108,7 @@ def check(lab, part):
width = min(120, shutil.get_terminal_size().columns)
if ista:
print(kwargs)
error("Check only works in the student context, you are in a TA course path!")
error("{} is a TA path for {}!".format(
coursepath, coursename
......
import click
import config
import courses
from utils.history import History
from utils.git import Git
from utils.gitlab import GitLab
from utils.mailer import send_email
import os.path
def send_feedback_email(labpath, coursename, student_id, labstr):
with History(location=labpath,
dbpath='{}/{}/{}'.format(coursename, student_id, labstr)) as hist:
if not 'grade' in hist:
error("No grade for {} on {}, grade it first!".format(student_id, labstr))
return
if not 'feedback' in hist['grade']:
error("No feedback for {} on {}, regrade if needed.".format(student_id, labstr))
return
who = f"{os.environ['USER']}@{config.email_domain}"
send_email(who, f"{student_id}@{config.email_domain}",
f"[{coursename}]: {labstr} feedback",
hist['grade']['feedback'])
@click.command(short_help="Email LAB feedback (TA only)")
@click.option('--lab', type=int, default=None)
@click.option("--user", default=None,
help='Set to only email a single user (by git username).\t[default: ALL]')
def email(lab, user, **kwargs):
"""
Email student feedback from feedback string in history object
"""
# convert lab number to filename magic.
if lab:
labstr = "lab{:02}".format(lab)
else:
labstr = None
coursename, coursepath, labname, ista = courses.detect_course(lab=labstr)
if not ista:
error("You are not a TA for the course {} in {}!".format(
coursename, coursepath))
return
if not coursename:
error("The current directory is not an initialized course! You must first cd into an initialized course to check!")
return
if not labname:
#TODO: concat all lab feedbacks if no lab is specified
error("Could not detect LAB. You must specify the LAB on the command line (--lab #)")
return
rubric = courses.load_rubric(coursename, labname)
if user == None:
# if no user given, search gitlab for users
gl = GitLab()
projects = gl.search_all_projects(coursename, owned=False)
for p in projects:
labpath = os.path.join(coursepath, p['owner']['username'], rubric['path'])
send_feedback_email(labpath, coursename, p['owner']['username'], labstr)
else:
labpath = os.path.join(coursepath, user, rubric['path'])
send_feedback_email(labpath, coursename, user, labstr)
import click
import click.exceptions
import config
from config.echo import *
import courses
......@@ -15,6 +16,7 @@ import shutil
import difflib
from commands.checker import Checker
from utils.history import History
from commands.report import make_feedback_msg
import io
import contextlib # to redirect stdout
......@@ -36,7 +38,7 @@ import contextlib # to redirect stdout
help='Skip the given user or list of users (comma separated)\t[default: None]')
@click.option("--push/--no-push", default = True, show_default=True,
help='Push commits to gitlab?')
def grade(lab, part, clone, dograde, regrade, user, skip, push):
def grade(lab, part, clone, dograde, regrade, user, skip, push, **kwargs):
"""grade LAB and PART for students from gitlab. LAB is required. If PART is omitted, ALL parts are graded.
"""
......@@ -201,16 +203,10 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
'output': end_capture_output(),
'who': who,
'at': utils.timestamp().isoformat()}
# if not dograde:
# # if they don't want to grade, bail out here.
# continue
#
# newline()
# echo("-"*width)
# newline()
# for partstr, info in rubric['parts'].items():
if dograde and 'grade' in info:
if not regrade and 'grade' in hist and partstr in hist['grade'] and 'grade' in hist['grade'][partstr] and 'comment' in hist['grade'][partstr]:
echo("Skip part {:3}, already graded (set regrade to re-run).".format(info['index']))
echo("Grade {} of {}: {}".format(
hist['grade'][partstr]['grade'],
......@@ -258,20 +254,26 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
c.do_check(stop_on_error=False, check_type='grade')
echo(info['prompt'])
grade = prompt('Grade', default = grade)
if not confirm('Use comment "{}"?'.format(grade_msg),
default=True):
grade_msg = click.edit(text=grade_msg)
hist['grade'][partstr] = {
'grade': grade,
'total': tot_pts,
'prompt': info['prompt'],
'comment': grade_msg if grade_msg else "",
'output': end_capture_output(),
'who': who,
'at': utils.timestamp().isoformat()}
try:
grade = prompt('Grade', default = grade)
if not confirm('Use comment "{}"?'.format(grade_msg),
default=True):
grade_msg = click.edit(text=grade_msg)
hist['grade'][partstr] = {
'grade': grade,
'total': tot_pts,
'prompt': info['prompt'],
'comment': grade_msg if grade_msg else "",
'output': end_capture_output(),
'who': who,
'at': utils.timestamp().isoformat()}
except click.exceptions.Abort:
end_capture_output()
warn("Ctrl-c -- abort.")
return
# if not regrade and 'grade' in hist and 'TOTAL' in hist['grade']:
# pass
......@@ -287,6 +289,7 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
hist['grade']['TOTAL']['grade'],
hist['grade']['TOTAL']['total']))
hist['grade']['feedback'] = make_feedback_msg(rubric, hist['grade'])
hfile = hist.filename
# EXIT history context to force update on disk,
......@@ -317,7 +320,3 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
))
else:
warn("Not pushing to gitlab by your command.")
# fb_filename = os.path.join(labpath, feedback)
# feedbackfile = Path(fb_filename)
# feedbackfile.write_text(report)
# click.edit(filename=fb_filename)
......@@ -7,6 +7,8 @@ https://python-prompt-toolkit.readthedocs.io/en/latest/pages/full_screen_apps.ht
"""
import sys
import click
from functools import partial
from prompt_toolkit import Application, prompt, print_formatted_text
from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.patch_stdout import patch_stdout
......@@ -40,6 +42,7 @@ import os
import platform
from config import UserConfig
import courses
from config.echo import *
from utils.history import History
from utils.git import Git
from utils.gitlab import GitLab
......@@ -47,6 +50,7 @@ from utils.debug_port import udbg
import commands
import threading
import time
from pprint import pprint
def get_statusbar_right_text(buf):
# returns a function to return the passed-in buffer pos
......@@ -54,7 +58,12 @@ def get_statusbar_right_text(buf):
buf.document.cursor_position_row + 1,
buf.document.cursor_position_col + 1,
)
def do_cmd(ctx, cmdstr, proj_list, lab_list, part_list):
# current selected lab path if single project/lab are selected
# generated when updating grades on student list after project/lab is selected
g_lab_paths = {}
student_names_by_id = {} # also generated by background thread
def do_cmd(ctx, cmdstr, projects, proj_list, lab_list, part_list):
# generic command event callback
udbg.send(" ".join(map(str, [cmdstr,
......@@ -64,8 +73,9 @@ def do_cmd(ctx, cmdstr, proj_list, lab_list, part_list):
xargs = {}
if cmdstr =='regrade':
cmdstr='grade'
cmdstr = 'grade'
xargs['regrade'] = True
t = getattr(commands, cmdstr)
def it():
ctx.invoke(t,
......@@ -73,10 +83,51 @@ def do_cmd(ctx, cmdstr, proj_list, lab_list, part_list):
user=proj_list.current_value,
part=part_list.current_value,
**xargs)
input ('Press enter to return to menu.')
input('Press enter to return to menu.')
global invalidate_projects
invalidate_projects = True
get_app().invalidate()
udbg.send("cmd complete invalidate ui")
# invalidate grade list, so when we redraw it gets new grades if they changed.
#global invalidate_projects
#invalidate_projects = True
# remove grade from the current selected project to update if chaged.
if proj_list.current_value == None:
udbg.send("removing grade for All")
proj_list.values = [(None, 'All')] + [
(p['owner']['username'], "{} ({})".format(p['owner']['username'], p['owner']['name']))
for p in projects]
else:
udbg.send("removing grade for {}".format(proj_list.current_value))
for p in projects:
if proj_list.current_value == p['owner']['username']:
for i, val in enumerate(proj_list.values):
if val[0] == proj_list.current_value:
udbg.send("found match at {}, remove grade".format(i))
proj_list.values[i] = (p['owner']['username'], "{} ({})".format(p['owner']['username'], p['owner']['name']))
#get_app().invalidate()
run_in_terminal(it)
# force ui redraw to trigger any updates needed by the cmd
# reset project list
# seems to be a bug in prompt toolkit that I can't invalidate immediately.
# def wait_then_invalidate(projects, proj_list):
# time.sleep(1)
# global invalidate_projects
# invalidate_projects = True
# get_app().invalidate()
# threading.Thread(target = wait_then_invalidate,
# name="Sleep/invalidate loop", args = (projects, proj_list),
# daemon = True).start()
def update_student_list_with_grades(coursename, coursepath, projects, proj_list, labstr):
"""
Query the db for grades and update student list accordingly.
......@@ -88,31 +139,32 @@ def update_student_list_with_grades(coursename, coursepath, projects, proj_list,
# for p in projects])
#for p in projects:
udbg.send("update_w_grades ({}, {}, {}. {})".format(
coursename, coursepath, proj_list, labstr))
udbg.send("update_w_grades ({}, {}, {})".format(
coursename, coursepath, labstr))
rubric = courses.load_rubric(coursename, labstr)
part_ids = [partinfo['index'] for name, partinfo in rubric['parts'].items()]
part_strs = [name for name, partinfo in rubric['parts'].items()]
student_names_by_id = {
p['owner']['username']: p['owner']['name'] for p in projects
}
global student_names_by_id
global g_lab_paths
g_lab_paths = {}
for pidx, val in enumerate(proj_list.values):
student_id, disp_str = val
if student_id == None:
continue
#split name from "gic004 (Gabriella Colletti)"
#student_name = disp_str.split(' ', 1)[-1][1:-1] # strip parens
student_name = student_names_by_id[student_id]
if student_id in student_names_by_id:
student_name = student_names_by_id[student_id]
else:
student_name = "Unknown"
labpath = os.path.join(coursepath, student_id, rubric['path'])
g_lab_paths[student_id] = labpath
ginfo = ' ('
with History(location=labpath, save=False,
dbpath='{}/{}/{}'.format(coursename, student_id, labstr)) as h:
# udbg.send("Load history for {}/{}/{}".format(coursename, student_id, labstr))
if 'grade' in h:
for i,part in zip(part_ids, part_strs):
if part in h['grade']:
......@@ -123,66 +175,74 @@ def update_student_list_with_grades(coursename, coursepath, projects, proj_list,
ginfo += "*"*len(part_ids)
ginfo += ')'
if 'grade' in h and 'TOTAL' in h['grade']:
ginfo += "[{:3}]".format(h['grade']['TOTAL']['total'])
ginfo += "[{:3}]".format(h['grade']['TOTAL']['grade'])
else:
ginfo += "[***]"
proj_list.values[pidx] = (student_id,
'{} ({})'.format(
student_id,
student_name).ljust(30) + ginfo)
# udbg.send("\t"+ginfo)
proj_str = '{} ({})'.format(
student_id,
student_name).ljust(30)
proj_str = "{}{}".format(proj_str, ginfo)
proj_list.values[pidx] = (student_id, proj_str)
get_app().invalidate()
# text = prompt('Done?')
# print ('got', text)
last_lab = None
rubrics = {}
invalidate_projects = False
def on_invalidate(app, coursename, coursepath, projects, proj_list, lab_list, part_list):
"when selected lab changes, update the part list correctly"
global last_lab
global rubrics
if lab_list.current_value != last_lab:
global invalidate_projects
if invalidate_projects or lab_list.current_value != last_lab:
udbg.send("performing update of lists.")
# reset project list
# proj_list.values = [(None, 'All')] + [
# (p['owner']['username'], "{} ({})".format(p['owner']['username'], p['owner']['name']))
# for p in projects]
if lab_list.current_value is None:
part_list.values = [(None, 'select lab first')]
else:
labstr = lab_list.values[lab_list._selected_index][1]
udbg.send("labstr is {}".format(labstr))
# udbg.send("labstr is {}".format(labstr))
if not labstr in rubrics:
rubrics[labstr] = courses.load_rubric(coursename, labstr)
parts = rubrics[labstr]['parts'].keys()
# regen part_list
#[None, 'All'] +
part_list.values = [(None, 'All')] + [
(rubrics[labstr]['parts'][p]['index'],
"{} [{}]".format(p, rubrics[labstr]['parts'][p]['index'])) for p in parts]
#part_list.current_values = []
#part_list.current_value = None
#part_list._selected_index = 0
"{}: {} [{}pts]".format(
rubrics[labstr]['parts'][p]['index'], p,
rubrics[labstr]['parts'][p]['points'])) for p in parts]
if lab_list.current_value != None:
threading.Thread(target = update_student_list_with_grades,
name="Project list grade update thread",
args=(coursename, coursepath, projects, proj_list, labstr), daemon = True).start()
# update_student_list_with_grades(coursename, coursepath, projects, proj_list, labstr)
else:
proj_list.values = [(None, 'All')] + [
(p['owner']['username'], "{} ({})".format(p['owner']['username'], p['owner']['name']))
for p in projects]
last_lab = lab_list.current_value
invalidate_projects = False
else:
udbg.send("invalidate but not updating lists.")
@click.command()
@click.pass_context
def menu(ctx):
"Menu based interaface for labtool."
coursename, coursepath, labname, ista = courses.detect_course()
global labtool_version # from main lt
global student_names_by_id
udbg.send("menu started")
if not ista:
error("You are not a TA for the course {} in {}!".format(
coursename, coursepath))
error("You probably meant to run some other command.")
error("You probably meant to run some other command or are not running from your TA directory.")
return
if not coursename:
error("The current directory is not an initialized course! You must first cd into an initialized course to check!")
......@@ -191,27 +251,24 @@ def menu(ctx):
gl = GitLab()
g = Git()
projects = gl.search_all_projects(coursename, owned=False)
labnames = courses.load_labnames(coursename)
#from pprint import pprint
#pprint(projects)
#exit()
who = "{}@{}".format(os.environ['USER'], platform.node())
student_names_by_id = {
p['owner']['username']: p['owner']['name'] for p in projects
}
buffer1 = Buffer()
who = "{}@{}".format(os.environ['USER'], platform.node())
proj_list = RadioList(values=[(None, 'All')] + [
(p['owner']['username'], "{} ({})".format(p['owner']['username'], p['owner']['name']))
for p in projects])
lab_list = RadioList(values=[(None, 'All')] + [(int(l[3:]), l) for l in labnames])
part_list = RadioList(values=[(None, 'select lab first')])
command_list = HSplit([
Button(text="check", handler=lambda: do_cmd(ctx, 'check', proj_list, lab_list, part_list)),
Button(text="grade", handler=lambda: do_cmd(ctx, 'grade', proj_list, lab_list, part_list)),
Button(text="regrade", handler=lambda: do_cmd(ctx, 'regrade', proj_list, lab_list, part_list)),
Button(text="report", handler=lambda: do_cmd(ctx, 'report', proj_list, lab_list, part_list)),
Button(text="exit", handler=lambda: get_app().exit(result=False))
])
command_list = HSplit(
[Button(text=cmd,
handler=partial(do_cmd, ctx, cmd, projects, proj_list, lab_list, part_list))
for cmd in ['grade', 'regrade', 'report', 'email']] + \
[Button(text="exit", handler=lambda: get_app().exit(result=False))]
)
verstr = "Labtool v{}".format(sys.modules['__main__'].__version__)
root_container = HSplit([
#Window(content=BufferControl(buffer=buffer1)),
......@@ -268,27 +325,32 @@ def menu(ctx):
event.app.layout.focus(part_list)
@kb.add("right", filter=has_focus(command_list))
def command_list_right(event):
event.app.layout.focus(buffer1)
event.app.layout.focus(proj_list)
@kb.add("c-m")
def root_ctrl_m(event):
event.app.layout.focus(root_container.window)
@kb.add('c-c')
def ctrl_c(event):
event.app.exit()
root_container = MenuContainer(
body=root_container,
menu_items=[
MenuItem("File",
children=[MenuItem("Exit", handler=lambda: get_app().exit(result=False))]),
MenuItem("Command",
children=[
MenuItem("check"),
MenuItem("grade"),
MenuItem("report")
]),
MenuItem("About")
])
@kb.add('c-r')
def refresh(event):
global invalidate_projects
invalidate_projects = True
event.app.invalidate()
# root_container = MenuContainer(
# body=root_container,
# menu_items=[
# MenuItem("File",
# children=[MenuItem("Exit", handler=lambda: get_app().exit(result=False))]),
# MenuItem("Command",
# children=[
# MenuItem("check"),
# MenuItem("grade"),
# MenuItem("report")
# ]),
# MenuItem("About")
# ])
style = Style.from_dict(
{
......
......@@ -15,7 +15,28 @@ from pprint import pprint
import numpy as np
from collections import defaultdict
from utils.debug_port import udbg
from pprint import pprint
def make_feedback_msg(rubric, parts):
"make string summary of feedback grades, rubric and hist['grades'] is passed in"
# get parts sorted by index
width=80
msg = []
for part in rubric['parts'].keys():
full_partstr= "({}) {}: {}".format(
rubric['parts'][part]['index'],
part, rubric['parts'][part]['prompt'])
msg.append("{}\n\tGrade: {:3} /{:3}".format(
full_partstr,
parts[part]['grade'],
parts[part]['total']
))
if 'comment' in parts[part]:
msg.append("\tComments: {}".format(parts[part]['comment']))
msg.append("TOTAL: {:3} /{:3}".format(
parts['TOTAL']['grade'],
parts['TOTAL']['total']
))
return "\n".join(msg)
def ta_report(coursename, coursepath, labname, user, clone):
if labname == None:
labnames = courses.load_labnames(coursename)
......@@ -53,14 +74,36 @@ def ta_report(coursename, coursepath, labname, user, clone):
else:
continue
report_parts = ["{}, {}".format(
## print feedbacks if avail.
for p in projects:
pname = p['name_with_namespace']
student = p['owner']['username']
url = p['ssh_url_to_repo']
if user and student != user:
continue
for rub in rubrics:
labname = rub['name']
labpath = os.path.join(coursepath, student, rub['path'])
with History(location=labpath, save=False,
dbpath='{}/{}/{}'.format(coursename, student, labname)) as h:
if 'grade' in h and 'feedback' in h['grade']:
print("-"*width)
print("{} ({}): {}".format(student,p['owner']['name'], labname))
print(h['grade']['feedback'])
##
## print CSV style summary report
report_header = ["{}, {}".format(
"student".ljust(8),
", ".join(
["L{}pt, L{}t".format(ln[3:5], ln[3:5]) for ln in labnames]))]
print(", ".join(report_parts))
print(", ".join(report_header))
c_tot = defaultdict(list)
# print reports
# print report summary
for p in projects:
pname = p['name_with_namespace']
student = p['owner']['username']
......@@ -104,6 +147,7 @@ def ta_report(coursename, coursepath, labname, user, clone):
print(", ".join(report_parts))
report_parts = ["STATS".ljust(8)]
for rub in rubrics:
labname = rub['name']
......@@ -178,8 +222,6 @@ def student_report(coursename, coursepath, labname, clone):
#r[part]['who'].split("@")[0])
echo ("-"*max(10,width-5))
@click.command(short_help="Show the grade report.")
@click.option('--lab', type=int, required=False, default=None,
help='The lab (number) to show the report for.')
......@@ -187,7 +229,7 @@ def student_report(coursename, coursepath, labname, clone):
help='Set to only report on a single user (by git username).')
@click.option("--clone/--no-clone", default=True,
help='Clone repo before showing report?\t[default: True]')
def report(lab, user, clone):
def report(lab, user, clone, **kwargs):
"""
print grade report, if in ta context all users, for students all lab scores
"""
......
from config.user import UserConfig
# email for crash reports
crash_reports = "amm042@bucknell.edu"
crash_reports_to = "amm042@bucknell.edu"
crash_reports_from = "labtool@eg.bucknell.edu"
# mail server for feedback and crash reports`
smtp_server = "smtp.bucknell.edu"
# domain name to make an email address from git user id
email_domain = "bucknell.edu"
# where UserConfig is stored. (cannot be overridden)