From 24ecfab8a9d69754cb8df36ee7d4fc5a9458a654 Mon Sep 17 00:00:00 2001 From: Jakob Date: Tue, 24 Dec 2019 21:09:21 -0600 Subject: [PATCH 1/1] Initial commit --- main.py | 645 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 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('', 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('', 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 = {'', '', ''} + 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('', scroll_bind) + self.bind('', 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() -- 2.30.2