Initial commit
authorJakob <jakob@jcornell.net>
Wed, 25 Dec 2019 03:09:21 +0000 (21:09 -0600)
committerJakob <jakob@jcornell.net>
Wed, 25 Dec 2019 03:09:21 +0000 (21:09 -0600)
main.py [new file with mode: 0644]

diff --git a/main.py b/main.py
new file mode 100644 (file)
index 0000000..91e7c40
--- /dev/null
+++ b/main.py
@@ -0,0 +1,645 @@
+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()