--- /dev/null
+from collections import defaultdict, namedtuple, OrderedDict
+from contextlib import contextmanager
+from enum import Enum
+import itertools
+import json
+from matplotlib import pyplot
+
+import tkinter as tk
+from tkinter import ttk
+from tkinter import (
+ filedialog,
+ messagebox,
+ simpledialog,
+)
+
+
+TK_PAD = {'padx': 5, 'pady': 5}
+
+
+class Linkable:
+ def on_linked(self):
+ pass
+
+class Model(Linkable):
+ def init(self, view):
+ self.view = view
+
+class View(Linkable):
+ def init(self, controller):
+ self.controller = controller
+
+class Controller(Linkable):
+ def init(self, model):
+ self.model = model
+
+ def __getattr__(self, name):
+ """ Expose the model API directly for convenience """
+ return getattr(self.model, name)
+
+
+def link(model, view, controller):
+ model.init(view)
+ view.init(controller)
+ controller.init(model)
+ model.on_linked()
+ view.on_linked()
+ controller.on_linked()
+
+
+# Main application
+
+def new_app_state():
+ return {
+ 'img_save_dir': None,
+ 'student': None,
+ }
+
+def new_app_data():
+ return {}
+
+
+TRACED_VAR = Enum('TRACED_VAR', [
+ 'STUDENT_SELECT',
+])
+
+
+class MainModel(Model):
+ def __init__(self, data_mgr, state_mgr):
+ def import_(tree):
+ return {
+ name: [DataEditModel.Record(*r) for r in records]
+ for (name, records) in tree.items()
+ }
+ self.data_mgr = data_mgr
+ data = self.data_mgr.load()
+ if data is None:
+ self.data = new_app_data()
+ else:
+ self.data = import_(data)
+
+ self.state_mgr = state_mgr
+ state = self.state_mgr.load()
+ self.state = state or new_app_state()
+
+ self.saved = False
+ self.dirty = False
+
+ def on_linked(self):
+ self.view.set_student_list(self.view_names())
+ self.view.set_student(self.state['student'])
+
+ def view_names(self):
+ return sorted(self.data.keys())
+
+ def add_student(self, name):
+ if name in self.data:
+ raise ValueError()
+ else:
+ self.data[name] = []
+ self.dirty = True
+ self.view.set_student_list(self.view_names())
+ self.view.update_controls()
+
+ def delete_student(self, name):
+ del self.data[name]
+ self.dirty = True
+ self.view.set_student_list(self.view_names())
+ if self.state['student'] == name:
+ self.select_auto()
+ self.view.update_controls()
+
+ def select_auto(self):
+ names = self.view_names()
+ self.select_student(names[0] if names else None)
+
+ def set_student(self, name):
+ assert name is None or name in self.data.keys()
+ self.state['student'] = name
+
+ def save_data(self):
+ self.data_mgr.store(self.data)
+ self.saved = True
+ self.dirty = False
+
+ def save_state(self):
+ self.state_mgr.store(self.state)
+
+ def select_student(self, name):
+ self.set_student(name)
+ with self.view.no_propagate(TRACED_VAR.STUDENT_SELECT):
+ self.view.set_student(name)
+
+
+class MainView(View):
+ @contextmanager
+ def no_propagate(self, key):
+ assert self.propagate[key]
+ self.propagate[key] = False
+ yield
+ self.propagate[key] = True
+
+ def __init__(self):
+ self.propagate = defaultdict(lambda: True)
+ self.root = tk.Tk()
+ self.root.title("Tutoring Tool")
+
+ def on_close():
+ if self.editor_views:
+ next(iter(self.editor_views.values())).root.lift()
+ elif (
+ not self.controller.dirty
+ or messagebox.askokcancel("Confirm", "You have unsaved changes. Quit anyway?")
+ ):
+ self.controller.save_state()
+ self.root.destroy()
+ self.root.protocol('WM_DELETE_WINDOW', on_close)
+
+ def on_save(*_):
+ try:
+ self.controller.save_data()
+ except:
+ messagebox.showerror("Error", "Error saving data")
+ menu = tk.Menu(self.root)
+ self.root.config(menu = menu)
+ file_menu = tk.Menu(menu, tearoff = 0)
+ file_menu.add_command(label = "Save", command = on_save, accelerator = "Ctrl+S")
+ menu.add_cascade(label = "File", menu = file_menu)
+ self.root.bind('<Control-s>', on_save)
+
+ self.student_frame = ttk.LabelFrame(self.root, text = "Student")
+
+ def on_student_change(*_):
+ if self.propagate[TRACED_VAR.STUDENT_SELECT]:
+ self.controller.select_student(self.student_var.get() or None)
+ self.student_var = tk.StringVar()
+ self.student_var.trace('w', on_student_change)
+ self.student_sel = ttk.OptionMenu(self.student_frame, self.student_var)
+ self.student_sel.configure(width = 25)
+
+ def on_student_add():
+ result = simpledialog.askstring("New student", "Name")
+ if result:
+ try:
+ self.controller.add_student(result)
+ except ValueError:
+ messagebox.showerror("Error", "Student already exists")
+ add_student_btn = ttk.Button(self.student_frame, text = "Add", command = on_student_add)
+
+ def on_student_del():
+ curr_name = self.controller.current_student()
+ result = messagebox.askokcancel("Confirm", "Delete student {}?".format(curr_name))
+ if result:
+ self.controller.delete_student(curr_name)
+ self.del_student_btn = ttk.Button(self.student_frame, text = "Delete", command = on_student_del)
+
+ actions_frame = ttk.LabelFrame(self.root, text = "Actions")
+
+ def on_edit_data():
+ self.controller.launch_editor()
+ self.edit_data_btn = ttk.Button(actions_frame, text = "Edit scores", command = on_edit_data)
+
+ def on_gen_plot():
+ dialog = tk.Toplevel(self.root)
+ dialog.title("Generate plot")
+ type_var = tk.StringVar(self.root)
+ select_frame = tk.Frame(dialog)
+ type_select = ttk.OptionMenu(select_frame, type_var)
+ type_select.set_menu("", "Scores", "Times")
+ label = tk.Label(select_frame, text = "Plot type:")
+ type_var.set("Scores")
+
+ dialog.protocol('WM_DELETE_WINDOW', dialog.destroy)
+ def on_gen():
+ plot_class = {"Scores": ScorePlotter, "Times": TimePlotter}[type_var.get()]
+ name = self.controller.state['student']
+ records = self.controller.data[name]
+ bar_sets = plot_class.get_bar_sets(records)
+ if bar_sets:
+ dialog.destroy()
+ plotter = plot_class(name)
+ plotter.plot_bar_sets(bar_sets)
+ plotter.decorate()
+ plotter.show()
+ else:
+ messagebox.showinfo("No data", "No data to plot")
+ gen_btn = ttk.Button(dialog, text = "Generate", command = on_gen)
+
+ label.pack(side = 'left', **TK_PAD)
+ type_select.pack(side = 'left', **TK_PAD)
+ select_frame.pack(**TK_PAD)
+ gen_btn.pack(**TK_PAD)
+ self.gen_plot_btn = ttk.Button(actions_frame, text = "Generate plot", command = on_gen_plot)
+
+ self.student_sel.pack(**TK_PAD, side = tk.LEFT)
+ add_student_btn.pack(**TK_PAD, side = tk.LEFT)
+ self.del_student_btn.pack(**TK_PAD, side = tk.LEFT)
+ self.student_frame.pack(**TK_PAD, fill = tk.X)
+ self.edit_data_btn.pack(**TK_PAD, fill = tk.X)
+ self.gen_plot_btn.pack(**TK_PAD, fill = tk.X)
+ actions_frame.pack(**TK_PAD, fill = tk.X)
+
+ self.editor_views = {}
+
+ def set_student_list(self, names):
+ self.student_sel.set_menu('', *names)
+
+ def set_student(self, name):
+ self.student_var.set('' if name is None else name)
+
+ def update_controls(self):
+ state = 'enable' if self.controller.data else 'disable'
+ students_only = [
+ self.student_sel,
+ self.del_student_btn,
+ self.edit_data_btn,
+ self.gen_plot_btn,
+ ]
+ for control in students_only:
+ control.configure(state = state)
+
+ def run(self):
+ self.update_controls()
+ self.root.mainloop()
+
+
+class MainController(Controller):
+ def add_student(self, name):
+ self.model.add_student(name)
+ self.model.select_student(name)
+
+ def current_student(self):
+ return self.model.state['student']
+
+ def launch_editor(self):
+ name = self.current_student()
+ model = DataEditModel(self.model, name)
+ view = DataEditView(self.model.view, self.model.view.root) # Encapsulation?
+ controller = DataEditController()
+ link(model, view, controller)
+
+
+# Data editor
+
+class DataEditModel(Model):
+ _Record = namedtuple('Record', [
+ 'comment',
+ 'e_score',
+ 'm_score',
+ 'r_score',
+ 's_score',
+ 'e_time',
+ 'm_time',
+ 'r_time',
+ 's_time',
+ ])
+ class Record(_Record):
+ @classmethod
+ def parse(class_, strings):
+ if len(strings) != len(class_._fields):
+ raise ValueError()
+ comment, *nums = strings
+ return class_(
+ comment,
+ *(int(num) if num else None for num in nums),
+ )
+
+ def overall_score(self):
+ scores = list(self[1:5])
+ if None in scores:
+ return None
+ else:
+ return sum(scores) / len(scores)
+
+ def __init__(self, base_model, student_name):
+ self.base_model = base_model
+ self.student_name = student_name
+
+ def on_linked(self):
+ self.view.set_records(self.base_model.data[self.student_name])
+
+
+class DataEditView(View):
+ def finish(self):
+ del self.parent_view.editor_views[self.controller.student_name]
+ self.root.destroy()
+
+ def __init__(self, parent_view, parent):
+ self.parent_view = parent_view
+
+ def on_close():
+ do = not self.dirty or messagebox.askokcancel("Confirm", "Discard changes?")
+ if do:
+ self.finish()
+ self.root = tk.Toplevel(parent)
+ self.root.protocol('WM_DELETE_WINDOW', on_close)
+ scroll_frame = VertScrollingFrame(self.root)
+ self.grid = scroll_frame.content_frame
+ buttons = tk.Frame(self.root)
+
+ def on_add():
+ (ord_, controls) = self.new_row(DataEditModel.Record(*[None] * 9))
+ self.rows[ord_] = controls
+ self.install_row(ord_, controls)
+ self.dirty = True
+ controls[0].focus_set()
+
+ add_btn = ttk.Button(self.root, text = "Add", command = on_add)
+
+ def on_ok():
+ try:
+ records = self.read_records()
+ except ValueError:
+ messagebox.showerror("Error", "Found invalid data; please correct and try again")
+ else:
+ self.controller.update_data(records)
+ self.finish()
+ ok_btn = ttk.Button(buttons, text = "OK", command = on_ok)
+ cancel_btn = ttk.Button(buttons, text = "Cancel", command = on_close)
+
+ add_btn.pack(**TK_PAD)
+ scroll_frame.pack()
+ ok_btn.pack(side = 'left', **TK_PAD)
+ cancel_btn.pack(side = 'left', **TK_PAD)
+ buttons.pack()
+
+ col_names = [
+ "Comment",
+ "Score (R)", "Score (W)", "Score (M)", "Score (S)",
+ "Time (R)", "Time (W)", "Time (M)", "Time (S)",
+ ]
+ labels = [tk.Label(self.grid, text = name, width = 10) for name in col_names]
+ for (col, label) in enumerate(labels):
+ label.grid(row = 0, column = col)
+ self.rows = OrderedDict()
+ self.ords = iter(itertools.count(1))
+ self.dirty = False
+
+ # Presumably tkinter variable objects aren't held by tkinter, but deconstructed
+ # (registered with Tk using only constituent parts).
+ # Tkinter deletes these variables from Tk when their Python wrappers are deleted,
+ # so we need to manage the lifetimes of the variables ourselves.
+ self.entry_vars = defaultdict(list)
+
+ def on_linked(self):
+ name = self.controller.student_name
+ existing = self.parent_view.editor_views.get(name)
+ if existing:
+ self.root.destroy()
+ existing.root.lift()
+ else:
+ self.parent_view.editor_views[name] = self
+ self.root.title("Editor: {}".format(name))
+
+ def new_row(self, record):
+ from functools import partial
+
+ parent = self.grid
+ ord_ = next(self.ords)
+
+ def on_delete():
+ for control in self.rows[ord_]:
+ control.destroy()
+ del self.rows[ord_]
+ del self.entry_vars[ord_]
+ self.dirty = True
+ del_btn = ttk.Button(parent, text = "Delete", command = on_delete)
+
+ vars_ = self.entry_vars[ord_] = [tk.StringVar(self.root) for _ in range(9)]
+
+ controls = tuple([
+ tk.Entry(parent),
+ *(tk.Entry(parent, width = 3) for _ in range(8)),
+ del_btn,
+ ])
+
+ def on_change(*_):
+ self.dirty = True
+ for (val, var, c) in zip(record, vars_, controls):
+ c.config(textvariable = var)
+ var.set('' if val is None else str(val))
+ var.trace('w', on_change)
+
+ return (ord_, controls)
+
+ def install_row(self, ord_, controls):
+ for (col, control) in enumerate(controls):
+ control.grid(row = ord_, column = col)
+
+ class DataError(Exception):
+ def __init__(self, row, column):
+ self.row = row
+ self.column = column
+
+ def read_records(self):
+ def to_record(controls):
+ return DataEditModel.Record.parse([c.get().strip() for c in controls[:-1]])
+ return list(map(to_record, self.rows.values()))
+
+ def set_records(self, records):
+ assert not self.rows
+ for (i, record) in enumerate(records):
+ (ord_, controls) = self.new_row(record)
+ self.rows[ord_] = controls
+ self.install_row(ord_, controls)
+
+
+class DataEditController(Controller):
+ def update_data(self, records):
+ self.base_model.data[self.student_name] = records
+ self.base_model.dirty = True
+
+
+class VertScrollingFrame(tk.Frame):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.canvas = tk.Canvas(self)
+ self.content_frame = tk.Frame(self.canvas)
+ self.bar = tk.Scrollbar(self, orient = "vertical", command = self.canvas.yview)
+ self.bar.lift(self.content_frame)
+ self.canvas.configure(yscrollcommand = self.bar.set)
+
+ self.bar.pack(side = "right", fill = "y")
+ self.canvas.pack(side = "left", fill = 'both', expand = True)
+ self.canvas.create_window(
+ (0, 0),
+ window = self.content_frame,
+ anchor = "nw",
+ tags = "self.content_frame",
+ )
+
+ def on_cfg(_):
+ (a, b, c, height) = self.canvas.bbox("all")
+ height = max(height, self.winfo_height() - 2)
+ self.canvas.configure(
+ scrollregion = (a, b, c, height),
+ width = self.content_frame.winfo_width(),
+ )
+ self.content_frame.bind('<Configure>', on_cfg)
+
+ def on_scroll(event):
+ if event.delta:
+ amount = round(-event.delta / 120)
+ else:
+ amount = {4: -1, 5: 1}[event.num]
+ self.canvas.yview_scroll(amount, "units")
+
+ bind_keys = {'<MouseWheel>', '<Button-4>', '<Button-5>'}
+ def scroll_bind(_):
+ for key in bind_keys:
+ self.bind_all(key, on_scroll)
+ def scroll_unbind(_):
+ for key in bind_keys:
+ self.unbind_all(key)
+
+ self.bind('<Enter>', scroll_bind)
+ self.bind('<Leave>', scroll_unbind)
+
+
+_SetTypeVal = namedtuple('SetTypeVal', ['name', 'color', 'model_key'])
+
+class _SetType(Enum):
+ ENGLISH = _SetTypeVal("English", 'violet', 'e')
+ MATH = _SetTypeVal("Math", 'blue', 'm')
+ READING = _SetTypeVal("Reading", 'red', 'r')
+ SCIENCE = _SetTypeVal("Science", 'green', 's')
+ OVERALL = _SetTypeVal("Overall", 'gray', None)
+
+
+class Plotter:
+ Point = namedtuple('Point', ['value', 'comment'])
+ SetType = _SetType
+
+ def __init__(self, student_name):
+ self.student_name = student_name
+ figure = pyplot.figure()
+ self.axes = pyplot.axes()
+ if self.Y_MAX is not None:
+ self.axes.set_ylim([0, self.Y_MAX])
+ figure.add_axes(self.axes)
+
+ def decorate(self):
+ self.axes.legend()
+ self.axes.set_title("{} for {}".format(self.PLOT_TYPE, self.student_name))
+ self.axes.set_ylabel(self.Y_LABEL)
+
+ @staticmethod
+ def get_overall_sets():
+ return None
+
+ @classmethod
+ def get_bar_sets(class_, records):
+ sets = OrderedDict()
+
+ def section_set(set_type):
+ attr = set_type.value.model_key + '_' + class_.DATA_KEY
+ return [
+ class_.Point(getattr(r, attr), r.comment)
+ for r in records
+ if getattr(r, attr) is not None
+ ]
+ sect_sets = [(type_, section_set(type_)) for type_ in list(class_.SetType)[:-1]]
+ for type_ in list(class_.SetType)[:-1]:
+ set_ = section_set(type_)
+ if set_:
+ sets[type_] = set_
+
+ overall_set = class_.get_overall_set(records)
+ if overall_set:
+ sets[class_.SetType.OVERALL] = overall_set
+
+ return sets
+
+ @staticmethod
+ def get_x_coords(sets):
+ x = 0
+ for set_ in sets:
+ yield range(x, x + len(set_))
+ x += len(set_) + 1
+
+ def plot_bar_sets(self, sets):
+ coord_sets = list(self.get_x_coords(sets.values()))
+ for ((type_, set_), coords) in zip(sets.items(), coord_sets):
+ self.axes.bar(
+ coords,
+ [p.value for p in set_],
+ 0.8,
+ color = type_.value.color,
+ label = type_.value.name,
+ )
+
+ ticks = [x for x in itertools.chain.from_iterable(coord_sets)]
+ labels = [p.comment for p in itertools.chain.from_iterable(sets.values())]
+ pyplot.xticks(ticks, labels, rotation = 'vertical')
+
+ def show(self):
+ pyplot.tight_layout()
+ pyplot.show()
+
+
+class ScorePlotter(Plotter):
+ PLOT_TYPE = "Scores"
+ DATA_KEY = 'score'
+ Y_LABEL = "Score"
+ Y_MAX = 36
+
+ @classmethod
+ def get_overall_set(class_, records):
+ return [
+ class_.Point(r.overall_score(), r.comment)
+ for r in records if r.overall_score() is not None
+ ]
+
+
+class TimePlotter(Plotter):
+ PLOT_TYPE = "Times"
+ DATA_KEY = 'time'
+ Y_LABEL = "Time"
+ Y_MAX = None
+
+ GOAL_TIMES = {
+ Plotter.SetType.ENGLISH: 45,
+ Plotter.SetType.MATH: 60,
+ Plotter.SetType.READING: 35,
+ Plotter.SetType.SCIENCE: 35,
+ }
+
+ @staticmethod
+ def get_overall_set(records):
+ return None
+
+ def plot_bar_sets(self, sets):
+ super().plot_bar_sets(sets)
+
+ coord_sets = self.get_x_coords(sets.values())
+ for ((type_, _), coords) in zip(sets.items(), coord_sets):
+ self.axes.hlines(self.GOAL_TIMES[type_], min(coords) - 0.5, max(coords) + 0.5)
+
+
+class AutoJsonStorageMgr:
+ UUID = '94b1dedb-f14f-470d-a4a0-043a56a9b46f-tutoring-tool'
+
+ def __init__(self, name):
+ import os.path, pathlib
+ path = pathlib.Path(os.path.expanduser('~'), 'AppData', self.UUID)
+ path.mkdir(parents = True, exist_ok = True)
+ self.path = path.joinpath(name)
+
+ def load(self):
+ if self.path.is_file():
+ with self.path.open() as f:
+ return json.load(f)
+ else:
+ assert not self.path.exists()
+ return None
+
+ def store(self, data):
+ with self.path.open('w') as f:
+ json.dump(data, f)
+
+
+model = MainModel(AutoJsonStorageMgr('data'), AutoJsonStorageMgr('state'))
+view = MainView()
+controller = MainController()
+link(model, view, controller)
+view.run()