From 6dffb40751959525d49e262b034086db8b9c603c Mon Sep 17 00:00:00 2001 From: Walker Arce Date: Mon, 19 Jun 2023 12:41:38 -0500 Subject: [PATCH] Closes #46 - Update license year - Update privacy policy date - Create template KSF - When no KSF is present, allow creation of new KSF from template - Fix versioning numbers - Allow KSF keys to be deleted and reassigned using popup - Create class for key actions to remove magic numbers - Create key edit functions - Update KSF revision function so all cells are rewritten --- LICENSE | 2 +- PRIVACY_POLICY.md | 2 +- ksf_utils.py | 69 +++++++++++----------- project_setup_ui.py | 115 +++++++++++++++++++++++++++--------- reference/Template_KSF.xlsx | Bin 0 -> 10951 bytes tkinter_utils.py | 36 ++++++++--- 6 files changed, 153 insertions(+), 71 deletions(-) create mode 100644 reference/Template_KSF.xlsx diff --git a/LICENSE b/LICENSE index d1bc7ef..d545a1d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Munroe Meyer Institute Virtual Reality Laboratory +Copyright (c) 2023 Munroe-Meyer Institute Virtual Reality Laboratory Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index cbd82bd..aef54cb 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,5 +1,5 @@ # Privacy Policy for cometrics -## Date: Aug 2022 +## Date: June 2023 The developer of cometrics is mindful of the security and protection of user data and patient data. This Privacy Policy document explains the types of information that are collected and recorded by cometrics and in which situations the developers will have access to generated data. diff --git a/ksf_utils.py b/ksf_utils.py index 48bcf46..88a8b36 100644 --- a/ksf_utils.py +++ b/ksf_utils.py @@ -760,45 +760,48 @@ def create_new_ksf_revision(original_ksf, keystrokes): name_cell = freq_headers[key][1] + '4' ws[name_cell].value = key + last_cell = freq_headers[list(freq_headers.keys())[0]][0] + i = 0 + difference = 0 if len(freq_headers) != len(keystrokes['Frequency']): difference = len(keystrokes['Frequency']) - len(freq_headers) - last_cell = freq_headers[key][0] - i = 1 - for key in keystrokes['Frequency'][-difference:]: - ws[increment_cell(last_cell, i)].value = key[0] - name_cell = increment_cell(last_cell, i)[:-1] + '4' - ws[name_cell].value = key[1] - i += 1 - for key in dur_headers: - dur_headers[key][0] = increment_cell(dur_headers[key][0], difference) - tracker_headers['Session Data'][3] += difference - tracker_headers['Frequency'][3] += difference - tracker_headers['Duration'][0] = increment_cell(tracker_headers['Duration'][0], difference) - tracker_headers['ST'][0] = increment_cell(tracker_headers['ST'][0], difference) - tracker_headers['PT'][0] = increment_cell(tracker_headers['PT'][0], difference) - tracker_headers['Session Time'][0] = increment_cell(tracker_headers['Session Time'][0], difference) - tracker_headers['Pause Time'][0] = increment_cell(tracker_headers['Pause Time'][0], difference) - + for key in keystrokes['Frequency']: + ws[increment_cell(last_cell, i)].value = key[0] + name_cell = increment_cell(last_cell, i)[:-1] + '4' + ws[name_cell].value = key[1] + i += 1 + # Offset the remaining headers so they don't collide with the frequency header + for key in dur_headers: + dur_headers[key][0] = increment_cell(dur_headers[key][0], difference) + tracker_headers['Session Data'][3] += difference + tracker_headers['Frequency'][3] += difference + tracker_headers['Duration'][0] = increment_cell(tracker_headers['Duration'][0], difference) + tracker_headers['ST'][0] = increment_cell(tracker_headers['ST'][0], difference) + tracker_headers['PT'][0] = increment_cell(tracker_headers['PT'][0], difference) + tracker_headers['Session Time'][0] = increment_cell(tracker_headers['Session Time'][0], difference) + tracker_headers['Pause Time'][0] = increment_cell(tracker_headers['Pause Time'][0], difference) + # Write the duration key headers for key in dur_headers: ws[dur_headers[key][0]].value = dur_headers[key][2] name_cell = dur_headers[key][0][:-1] + '4' ws[name_cell].value = key - + # Get the first duration key cell + last_cell = dur_headers[list(dur_headers.keys())[0]][0] + i = 0 + difference = 0 if len(dur_headers) != len(keystrokes['Duration']): difference = len(keystrokes['Duration']) - len(dur_headers) - last_cell = dur_headers[key][0] - i = 1 - for key in keystrokes['Duration'][-difference:]: - ws[increment_cell(last_cell, i)].value = key[0] - name_cell = increment_cell(last_cell, i)[:-1] + '4' - ws[name_cell].value = key[1] - i += 1 - tracker_headers['Session Data'][3] += difference - tracker_headers['Duration'][3] += difference - tracker_headers['ST'][0] = increment_cell(tracker_headers['ST'][0], difference) - tracker_headers['PT'][0] = increment_cell(tracker_headers['PT'][0], difference) - tracker_headers['Session Time'][0] = increment_cell(tracker_headers['Session Time'][0], difference) - tracker_headers['Pause Time'][0] = increment_cell(tracker_headers['Pause Time'][0], difference) + for key in keystrokes['Duration']: + ws[increment_cell(last_cell, i)].value = key[0] + name_cell = increment_cell(last_cell, i)[:-1] + '4' + ws[name_cell].value = key[1] + i += 1 + tracker_headers['Session Data'][3] += difference + tracker_headers['Duration'][3] += difference + tracker_headers['ST'][0] = increment_cell(tracker_headers['ST'][0], difference) + tracker_headers['PT'][0] = increment_cell(tracker_headers['PT'][0], difference) + tracker_headers['Session Time'][0] = increment_cell(tracker_headers['Session Time'][0], difference) + tracker_headers['Pause Time'][0] = increment_cell(tracker_headers['Pause Time'][0], difference) for key in skip_headers: if type(tracker_headers[key]) is list: @@ -815,9 +818,9 @@ def create_new_ksf_revision(original_ksf, keystrokes): ksf_dir = pathlib.Path(original_ksf).parent ksf_count = len(glob.glob1(ksf_dir, "*.xlsx")) if ksf_count > 1: - new_ksf = f"{original_ksf[:-8]}_V{ksf_count + 1}.xlsx" + new_ksf = f"{original_ksf[:-8]}_V{ksf_count}.xlsx" else: - new_ksf = f"{original_ksf[:-5]}_V{ksf_count + 1}.xlsx" + new_ksf = f"{original_ksf[:-5]}_V{ksf_count}.xlsx" wb.save(new_ksf) return new_ksf diff --git a/project_setup_ui.py b/project_setup_ui.py index f48e278..0d9e4b8 100644 --- a/project_setup_ui.py +++ b/project_setup_ui.py @@ -2,6 +2,7 @@ import os import pathlib import traceback +from shutil import copy2 from tkinter import * from tkinter import messagebox, filedialog from menu_bar import MenuBar @@ -18,6 +19,13 @@ small_treeview_rowheight, large_button_size, medium_button_size, small_button_size, ui_title +class KeyActions: + FREQUENCY_CREATE = 1 + DURATION_CREATE = 2 + FREQUENCY_EDIT = 3 + DURATION_EDIT = 4 + + class ProjectSetupWindow: def __init__(self, config, first_time_user): self.config = config @@ -126,13 +134,20 @@ def __init__(self, config, first_time_user): font=(self.field_font[0], self.field_font[1], 'italic'), bg='white', width=30, anchor='w') self.ksf_path.place(x=10 + self.window_width / 2, y=ptp[1], anchor=NW, - width=int(self.window_width * 0.3), height=self.button_size[1]) + width=int(self.window_width * 0.25), height=self.button_size[1]) self.ksf_import = Button(self.main_root, text="Import", font=self.field_font, width=10, command=self.import_concern_ksf) self.ksf_import.place(x=self.window_width * 0.77 + self.button_size[0] + 10, y=ptp[1], width=self.button_size[0], height=self.button_size[1], anchor=NW) self.ksf_import.config(state='disabled') + + self.ksf_new = Button(self.main_root, text="New", font=self.field_font, width=10, + command=self.create_new_ksf) + self.ksf_new.place(x=self.window_width * 0.77 + self.button_size[0], y=ptp[1], + width=self.button_size[0], height=self.button_size[1], anchor=NE) + self.ksf_new.config(state='disabled') + # Define frequency and duration key headers freq_heading_dict = {"#0": ["Frequency Key", 'w', 1, YES, 'w']} dur_heading_dict = {"#0": ["Duration Key", 'w', 1, YES, 'w']} @@ -474,33 +489,61 @@ def load_ksf(self): except ValueError: self.ksf_file = None self.ksf_path['text'] = f"No KSF in {self.selected_concern} {self.phases_var.get()}" - self.ksf_import.config(state='active') + self.ksf_import.config(state='normal') + self.ksf_new.config(state='normal') self.clear_duration_treeview() self.clear_frequency_treeview() + def create_new_ksf(self): + self.tracker_file = os.path.join(self.ksf_dir, f"{self.patient_container.name}_KSF.xlsx") + copy2(r'reference\Template_KSF.xlsx', self.tracker_file) + new_ksf_file, new_keystrokes = import_ksf(self.tracker_file, self.ksf_dir) + self._ksf = new_keystrokes + self.ksf_file = new_ksf_file + self.clear_duration_treeview() + self.clear_frequency_treeview() + self.load_ksf() + self.ksf_new.config(state='disabled') + print("INFO: Successfully created tracker spreadsheet") + def generate_ksf(self): - new_tracker_file = create_new_ksf_revision(self.tracker_file, self._ksf) - new_ksf_file, new_keystrokes = import_ksf(new_tracker_file, self.ksf_dir) - if compare_keystrokes(self._ksf, new_keystrokes): - self.tracker_file = new_tracker_file - self._ksf = new_keystrokes - self.ksf_file = new_ksf_file - self.clear_duration_treeview() - self.clear_frequency_treeview() - self.load_ksf() - print("INFO: Successfully updated tracker spreadsheet") + if len(self._ksf['Duration']) == 0 or len(self._ksf['Frequency']) == 0: + messagebox.showerror("Error", "There must be at least one Frequency and Duration key!") + print("ERROR: There must be at least one Frequency and Duration key!") else: - messagebox.showerror("Error", "Failed to update tracker spreadsheet!") - print("ERROR: Failed to update tracker spreadsheet") + new_tracker_file = create_new_ksf_revision(self.tracker_file, self._ksf) + new_ksf_file, new_keystrokes = import_ksf(new_tracker_file, self.ksf_dir) + if compare_keystrokes(self._ksf, new_keystrokes): + self.tracker_file = new_tracker_file + self._ksf = new_keystrokes + self.ksf_file = new_ksf_file + self.clear_duration_treeview() + self.clear_frequency_treeview() + self.load_ksf() + self.generate_button.config(state='disabled') + print("INFO: Successfully updated tracker spreadsheet") + else: + messagebox.showerror("Error", "Failed to update tracker spreadsheet!") + print("ERROR: Failed to update tracker spreadsheet") - def key_popup_return(self, tag, key, caller): + def key_popup_return(self, tag, key, caller, index, delete=False): if not tag or not key: messagebox.showwarning("Warning", "Invalid key entered! Please try again.") print(f"WARNING: Invalid key entered {tag} {key} {caller}") - if caller == 1: + return + if delete: + if caller == KeyActions.FREQUENCY_EDIT or caller == KeyActions.FREQUENCY_CREATE: + self.delete_frequency_key(index) + elif caller == KeyActions.DURATION_EDIT or caller == KeyActions.DURATION_CREATE: + self.delete_duration_key(index) + elif caller == KeyActions.FREQUENCY_CREATE: self.create_frequency_key(tag, key) - elif caller == 2: + elif caller == KeyActions.DURATION_CREATE: self.create_duration_key(tag, key) + elif caller == KeyActions.FREQUENCY_EDIT: + self.edit_frequency_key(index, tag, key) + elif caller == KeyActions.DURATION_EDIT: + self.edit_duration_key(index, tag, key) def load_concern_ksf(self): with open(self.ksf_file) as f: @@ -568,42 +611,60 @@ def populate_duration_treeview(self): def select_frequency_key(self, event): selection = self.frequency_key_treeview.identify_row(event.y) if selection: - if selection == '0': - NewKeyPopup(self, self.main_root, 1) + selection = int(selection) - 1 + if selection == -1: + NewKeyPopup(self, self.main_root, KeyActions.FREQUENCY_CREATE) else: - self.delete_frequency_key(int(selection) - 1) + NewKeyPopup(self, self.main_root, KeyActions.FREQUENCY_EDIT, index=selection, + key=self._ksf['Frequency'][selection][0], + tag=self._ksf['Frequency'][selection][1]) def select_duration_key(self, event): selection = self.duration_key_treeview.identify_row(event.y) if selection: - if selection == '0': - NewKeyPopup(self, self.main_root, 2) + selection = int(selection) - 1 + if selection == -1: + NewKeyPopup(self, self.main_root, KeyActions.DURATION_CREATE) else: - self.delete_duration_key(int(selection) - 1) + NewKeyPopup(self, self.main_root, KeyActions.DURATION_EDIT, index=selection, + key=self._ksf['Duration'][selection][0], + tag=self._ksf['Duration'][selection][1]) def create_frequency_key(self, tag, key): self._ksf['Frequency'].append([str(key), str(tag)]) clear_treeview(self.frequency_key_treeview) self.populate_frequency_treeview() - self.generate_button.config(state='active') + self.generate_button.config(state='normal', background='#4abb5f') def create_duration_key(self, tag, key): self._ksf['Duration'].append([str(key), str(tag)]) clear_treeview(self.duration_key_treeview) self.populate_duration_treeview() - self.generate_button.config(state='active') + self.generate_button.config(state='normal', background='#4abb5f') def delete_frequency_key(self, index): self._ksf['Frequency'].pop(index) clear_treeview(self.frequency_key_treeview) self.populate_frequency_treeview() - self.generate_button.config(state='active') + self.generate_button.config(state='normal', background='#4abb5f') def delete_duration_key(self, index): self._ksf['Duration'].pop(index) clear_treeview(self.duration_key_treeview) self.populate_duration_treeview() - self.generate_button.config(state='active') + self.generate_button.config(state='normal', background='#4abb5f') + + def edit_frequency_key(self, index, tag, key): + self._ksf['Frequency'][index] = ([str(key), str(tag)]) + clear_treeview(self.frequency_key_treeview) + self.populate_frequency_treeview() + self.generate_button.config(state='normal', background='#4abb5f') + + def edit_duration_key(self, index, tag, key): + self._ksf['Duration'][index] = ([str(key), str(tag)]) + clear_treeview(self.duration_key_treeview) + self.populate_duration_treeview() + self.generate_button.config(state='normal', background='#4abb5f') # endregion diff --git a/reference/Template_KSF.xlsx b/reference/Template_KSF.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f798cdea20b6d771eb3bf0db24cd94a1748a1b76 GIT binary patch literal 10951 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%0A@j@XB@(M za6VU2>0ow58N`Fz+uKABsdFFhD@5ExV$CIoIc!H3;wREJx9|GWZ{uK)jS?hG`KLWvp4^`$My2U)js)gw_Z)>@f1W5+Ict(HX_uao!>ovB@- zwpCqSqFOkCcu9#%?QtW#BEH6=ER0TSX2tXH(tbbSNg{;N#of4V9++PO#I7YD{I@U}LKe!{@n$+w(m@Mi5>ZkH10&2hV`e#P?08s&jl_@X}N*}1YmI*M*8 zIa#mF3lAj-I8$?TizP>cZ^eRNM!l3Ft=jF79MM7Mic#2(%&)q1_icrqGlG6A9^Pwp zMG~Q!RMUr;1lQ)eX;rmn>9g0a_@j6zW5`tWEr)^mt$n?N5RfSFG?D=tE_ zV8+coBAT(xLCchN&J`xh%28756*Fx_x?L=eUA-I4`0Fs(c$vdao`?1S(c0f+7w^PgtgZc0~TPIWimD zIsku?)=NwLe|>@%(|N=x$#k<|1g=0miwwG_=l8OTEcgy(5x}TptOz?aV5W0WOxQdx z*DhHA*Nu-}UpH^-#Z@Aegtm1wHO)YMf)zqFn;6nMhPzNM-=GZ3*w#8LoKegn=c%Qu ztaLC;F=OPL`{r}W`GqE?*0>A9?6}nshqoHKR~L=oO~}GGx})x?qpG!a|6@Q=07Dh{ z809s=b;qo%sFScXE9q#F^(!9?96ZIG)^3j6oBsVyx@*(!oRN%@D+%-uZ+bIp3UGP{ zJ?$<{4w{&DL8RgX3a1M{PTe#(E1bRQ!HP!;47$Lt%Ncq5%Lh)&CiN@VzS?%>HgFC! zZ_GK-LQbN1^!P>yV{p9^q_3QN6sZ#jN-N{3pv9L-!!}E4im%)^w%^^G84G0=J}ZnE zfkJkz#gxTq@`iCw5ojDeP6>x>|Izn?h07(PUwn`01?eb%_B}&8yZ^b~i^=}IGUDVc z7k{=~0-vDs+||F<2Y?3PfKzcIGT0yDvx?4K8OdaX(^8h6?xL*MUDHK!8*W|QF3sLg zAAWWei>6&zrMGXT6Z2gTn7=coBtp`!6bL0lzroWv(FkRKZ(q97uF{M_Mg>ZNuRm!- zV^ms{OqB~K+{A3)cS(zRLn1$=6jWIK>e$ciXgppgc`LF9J-4#DEvzOtg@Bv?vC^Dy zq$8Oad3&Hk%QI?16QZYmdZk>7G&m@Xnc81|5U9esL^SFMa{5*fH>`Sy$g>Elw&?(> zp4H_Xp$6q1b`+MWDWnr22CACer}x)m^)e6etg3zrHt9q+Mg-B~8e_9lKC