Commit 1579eb58 authored by Alan Marchiori's avatar Alan Marchiori
Browse files

Merge branch 'dbbackend' into 'master'

Dbbackend

See merge request !3
parents fb990dcf b0f399bd
......@@ -57,8 +57,12 @@ You will now have a folder like **CSCI206-SEMESTER-SECTION-TA**. If you want thi
Change into the newly created folder. You will see it is currently empty. The grade command will search and clone all student repos for this course/section/semester.
The main command you will use
For 2020 and beyond, we have a new database to hold grades, you have to configure this option in labtool. Your instructor will send you the needed <user> and <pass>.
```
$ lt userconfig set gradebackend --value mongodb
$ lt userconfig set gradedb --value "mongodb://<user>:<pass>@eg-mongodb.bucknell.edu/csgrades"
```
## Global configuration
......
......@@ -4,4 +4,6 @@ from .userconfig import userconfig
from .test import test
from .grade import grade
from .report import report
__all__ = ['userconfig', 'init', 'check', 'test', 'grade', 'report']
from .menu import 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
from config.echo import *
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}"
subj = f"[{coursename}]: {labstr} feedback"
toemail = f"{student_id}@{config.email_domain}"
send_email(config.feedback_email_from,
toemail,
subj,
hist['grade']['feedback'])
echo (f"SENT: {toemail} <-- {subj}")
@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.
"""
......@@ -52,10 +54,6 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
coursename, coursepath))
error("You probably meant to run the check command, not grade.")
return
debug('COURSENAME: ', coursename)
debug('COURSEPATH: ', coursepath)
if not coursename:
error("The current directory is not an initialized course! You must first cd into an initialized course to check!")
return
......@@ -63,6 +61,9 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
error("Could not detect LAB. Run this command from your lab folder or specify the LAB on the command line (--lab #)")
return
debug('COURSENAME: ', coursename)
debug('COURSEPATH: ', coursepath)
who = "{}@{}".format(os.environ['USER'], platform.node())
msg = "Grading {}".format(labname)
......@@ -135,8 +136,6 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
else:
continue
if not os.path.isdir(labpath):
error(labpath+ " does not exist!")
if confirm("Do you want to create the student lab directory (this is required to grade)?", default=True):
......@@ -146,9 +145,10 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
error("Path doesn't exist, cannot grade!")
continue
#print('getting history for {} {} on lab {}.'.format(coursename, student, labname))
# check for grade history
hfile = None
with History(labpath) as hist:
with History(location=labpath, dbpath='{}/{}/{}'.format(coursename, student, labstr)) as hist:
presults = []
if not 'check' in hist:
hist['check'] = {}
......@@ -203,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'],
......@@ -260,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
......@@ -289,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,
......@@ -296,29 +297,26 @@ def grade(lab, part, clone, dograde, regrade, user, skip, push):
# add hist.pathfile to GIT and PUSH.
# if not g.clean(cwd=labpath) or g.file_diff(cwd=labpath,
# files=[hfile]):
if push:
# only check if the history file has changes. we won't automatically
# push any other files!
if g.file_diff(cwd=labpath, files=[hfile]):
g.do_push(cwd = labpath,
addfiles = [hfile],
message = 'Lab {} graded by {}'.format(
lab,
who
))
if not g.clean(cwd=labpath):
echo(g.status(cwd=labpath))
if confirm("The working directory is not clean, do you want to add & push ALL changes?"):
g.do_push(cwd=labpath, addall=True,
# hfile is set to none for db backends...
if hfile != None:
if push:
# only check if the history file has changes. we won't automatically
# push any other files!
if g.file_diff(cwd=labpath, files=[hfile]):
g.do_push(cwd = labpath,
addfiles = [hfile],
message = 'Lab {} graded by {}'.format(
lab,
who
))
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)
if not g.clean(cwd=labpath):
echo(g.status(cwd=labpath))
if confirm("The working directory is not clean, do you want to add & push ALL changes?"):
g.do_push(cwd=labpath, addall=True,
message = 'Lab {} graded by {}'.format(
lab,
who
))
else:
warn("Not pushing to gitlab by your command.")
"""
This is the GUI (console) interface. This is mainly for TAs but may be later
updated for studnets.
This uses prompt_toolkit
https://python-prompt-toolkit.readthedocs.io/en/latest/pages/full_screen_apps.html
"""
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
from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import has_focus
from prompt_toolkit.filters.utils import to_filter
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout.containers import HSplit, VSplit, Window, WindowAlign
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.styles import Style
from prompt_toolkit.widgets import (
Box,
Button,
Checkbox,
Dialog,
Frame,
Label,
MenuContainer,
MenuItem,
ProgressBar,
RadioList,
Checkbox,
CheckboxList,
TextArea,
)
from prompt_toolkit.utils import Event
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
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
return lambda: "{}:{} ".format(
buf.document.cursor_position_row + 1,
buf.document.cursor_position_col + 1,
)
# 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,
proj_list.current_value,
lab_list.current_value,
part_list.current_value])))
xargs = {}
if cmdstr =='regrade':
cmdstr = 'grade'
xargs['regrade'] = True
t = getattr(commands, cmdstr)
def it():
ctx.invoke(t,
lab=lab_list.current_value,
user=proj_list.current_value,
part=part_list.current_value,
**xargs)
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.
This is run from a backround thread becuase it could be slow.
"""
# proj_list = RadioList(values=[(None, 'All')] + [
# (p['owner']['username'], "{} ({})".format(p['owner']['username'], p['owner']['name']))
# for p in projects])
#for p in projects:
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()]
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
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']:
ginfo += str(i)
else:
ginfo += '*'
else:
ginfo += "*"*len(part_ids)
ginfo += ')'
if 'grade' in h and 'TOTAL' in h['grade']:
ginfo += "[{:3}]".format(h['grade']['TOTAL']['grade'])
else:
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
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))
if not labstr in rubrics:
rubrics[labstr] = courses.load_rubric(coursename, labstr)
parts = rubrics[labstr]['parts'].keys()
# regen part_list
part_list.values = [(None, 'All')] + [
(rubrics[labstr]['parts'][p]['index'],
"{}: {} [{}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()
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 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!")
return
# search and clone student repos....
gl = GitLab()
g = Git()
projects = gl.search_all_projects(coursename, owned=False)
labnames = courses.load_labnames(coursename)
student_names_by_id = {
p['owner']['username']: p['owner']['name'] for p in projects
}
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=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)),
VSplit(
[
Frame(title="{} Students".format(coursename), body=proj_list),
Frame(title="Labs", body=lab_list),
Frame(title="Parts", body=part_list),
Frame(title="Commands", body=command_list)
]
),
#Frame(body=Window(height=40,content=BufferControl(buffer=buffer1))),
VSplit(
[
Window(
FormattedTextControl("{}@{}".format(os.environ['USER'], platform.node())), style="class:status"
),
Window(
FormattedTextControl("Press Ctrl-c to exit."),
style="class:status"
),
Window(
FormattedTextControl(verstr, style="class:status"),
width=len(verstr)
),
# Window(
# FormattedTextControl(get_statusbar_right_text(buffer1)),
# style="class:status.right",
# width=9,
# align=WindowAlign.RIGHT,
# ),
],
height=1,
)
])
kb = KeyBindings()
kb.add("tab")(focus_next)
kb.add("s-tab")(focus_previous)
# keybinds for command menu buttons that look like a list
kb.add("up", filter=has_focus(command_list))(focus_previous)
kb.add("down", filter=has_focus(command_list))(focus_next)
f = to_filter(
has_focus(proj_list) |
has_focus(lab_list) |
has_focus(part_list))
# keybinds for command menu right left
kb.add("left", filter=f)(focus_previous)
kb.add("right", filter=f)(focus_next)
@kb.add("left", filter=has_focus(command_list))
def command_list_left(event):
event.app.layout.focus(part_list)
@kb.add("right", filter=has_focus(command_list))
def command_list_right(event):
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')