diff --git a/RWTH Misc.ipynb b/RWTH Misc.ipynb index 78de047c02547ae8720598c2fd04e9ba6647c301..6568c4a0941f5f18bc6a3d9deef07804862f971c 100644 --- a/RWTH Misc.ipynb +++ b/RWTH Misc.ipynb @@ -25,14 +25,14 @@ "\n", "mail_to = \"example@rwth-aachen.de\" # send feedback via mail\n", "feedback_name = rwth_feedback.get_notebook_name() # get name of notebook automatically\n", - "rwth_feedback.rwth_feedback(feedback_name, [\n", + "rwth_feedback.RWTHFeedback(feedback_name, [\n", " {'id': 'likes', 'type': 'free-text', 'label': 'Das war gut:'}, \n", " {'id': 'dislikes', 'type': 'free-text', 'label': 'Das könnte verbessert werden:'}, \n", " {'id': 'misc', 'type': 'free-text', 'label': 'Was ich sonst noch sagen möchte:'}, \n", " {'id': 'learning', 'type': 'scale', 'label' : 'Ich habe das Gefühl etwas gelernt zu haben.'},\n", " {'id': 'supervision', 'type': 'scale', 'label' : 'Die Betreuung des Versuchs war gut.'},\n", " {'id': 'script', 'type': 'scale', 'label' : 'Die Versuchsunterlagen sind verständlich.'},\n", - "], \"feedback.json\", mail_to)" + "], \"feedback.json\", mail_to);" ] }, { diff --git a/rwth_nb/misc/feedback.py b/rwth_nb/misc/feedback.py index d2a8834b0c3cbfe544fb8acd1439fa727607b725..1f23b2b78eab3cb6ad749c36f8049078625d3f63 100644 --- a/rwth_nb/misc/feedback.py +++ b/rwth_nb/misc/feedback.py @@ -35,144 +35,315 @@ def get_notebook_name(): return 'tmp' -def rwth_feedback(feedback_name, questions, feedback_path='feedback.json', mail_to=None, mail_from='feedback@jupyter.rwth-aachen.de', mail_subject=None, mail_smtp_host='smarthost.rwth-aachen.de'): - global widgets_container - is_feedback_edited = is_send_confirmed = False +class RWTHFeedback(): + """ + RWTH Feedback submission class - def on_feedback_edited(_): - nonlocal is_feedback_edited - is_feedback_edited = True + Use as described in RWTH\ Misc.ipynb + """ + is_send_confirmed = is_saved_locally = is_mail_sent = False # Internationalization - feedback_scale_options_de = ['Stimme voll zu', 'Ich stimme zu', 'Keine Meinung', 'Ich stimme nicht zu', 'Ich stimme gar nicht zu'] # Likert-scale - feedback_text_de = {"your-feedback" : "Dein Feedback ...", - "send" : "Absenden", - "confirm_send" : "Zum Absenden bitte bestätigen.", - "sent" : "Dein Feedback wurde abgeschickt. Vielen Dank!", - "empty" : "Bitte geben Sie zuerst Feedback ein, das abgesendet werden kann.", - "mailfailure": "Die Mail mit dem Feedback konnte nicht versendet werden. Das Feedback wurde lokal abgespeichert."} + feedback_scale_options_de = ['Stimme voll zu', 'Ich stimme zu', 'Keine Meinung', 'Ich stimme nicht zu', + 'Ich stimme gar nicht zu'] # Likert-scale + feedback_text_de = {"your-feedback": "Dein Feedback ...", + "send": "Abschicken", + "confirm_send": "Abschicken bestätigen.", + "sent": "Dein Feedback wurde abgeschickt. Vielen Dank!", + "empty": "Bitte geben Sie zuerst Feedback ein, das abgesendet werden kann.", + "mailfailure": "Die Mail mit dem Feedback konnte nicht versendet werden. Das Feedback wurde lokal abgespeichert."} # TODO: Add at least English here - + # Select language - feedback_scale_options = feedback_scale_options_de; feedback_text = feedback_text_de; - + feedback_scale_options = feedback_scale_options_de; + feedback_text = feedback_text_de; + # Default arguments for toggle_button and textarea - toggle_args = {"options" : feedback_scale_options, - "index" : 2, "description" : "", "disabled" : False, "style": {'button_color': rwth_colors['rwth:green-50']}, "tooltips" : []} - textarea_args = {"value" : "", "placeholder" : feedback_text['your-feedback'], - "description" : "", "disabled" : False} - - widgets_container = []; - widgets_values = []; - for question in questions: - if question['type'] == 'free-text': - # Free text - widget_label = widgets.HTML(value="<b>{}</b>".format(question['label'])) - widget_value = widgets.Textarea(**textarea_args) - widget_value.observe(on_feedback_edited, 'value') - - elif question['type'] == 'scale': - # Toggle Buttons - widget_label = widgets.HTML(value="<b>{}</b>".format(question['label'])) - widget_value = widgets.ToggleButtons(**toggle_args) - widget_value.observe(on_feedback_edited, 'value') - - widgets_container.append(widget_label) - widgets_container.append(widget_value) - widgets_values.append(widget_value) # TODO: Get rid of this? - - # Button - button = widgets.Button(description = feedback_text['send'], disabled = False, style= {'button_color': rwth_colors['rwth:green-50'], 'margin':'10px'}, icon='', layout=Layout(margin='20px 0 0 0')) - output = widgets.Output() - - # Button click callback - def on_button_clicked(b): - nonlocal is_send_confirmed - - if not button.disabled: - entry = {} - - nonlocal is_feedback_edited - if not is_feedback_edited: - with output: - print(feedback_text['empty']) + toggle_args = {"options": feedback_scale_options, + "index": 2, "description": "", "disabled": False, + "style": {'button_color': rwth_colors['rwth:green-50']}, "tooltips": []} + textarea_args = {"value": "", "placeholder": feedback_text['your-feedback'], + "description": "", "disabled": False} + + def __init__(self, feedback_name, questions, feedback_path='feedback.json', mail_to=None, + mail_from='feedback@jupyter.rwth-aachen.de', mail_subject=None, + mail_smtp_host='smarthost.rwth-aachen.de'): + self.feedback_name = feedback_name + self.questions = questions + self.feedback_path = feedback_path + self.mail_to = mail_to + self.mail_from = mail_from + self.mail_subject = mail_subject + self.mail_smtp_host = mail_smtp_host + + self.feedback_status = { + 'idle': self.ui_state_idle, + 'saved_locally': self.ui_state_saved_locally, + 'mail_sent': self.ui_state_mail_sent + } + + # self.widgets_container: + # dict containing to each id key as defined in q a widget + # i.e. {'likes': widgets.Textarea, ...} + self.widgets_container = {} + + # self.widgets_VBoxes: + # list containing vertical boxes with labels and according widgets + # used for ui design + self.widgets_VBoxes = [] + + self.entry = {} + self.entries = [] + + # set up UI + self.setup_ui() + + def setup_ui(self): + """ + Set up user interface according to given questions + """ + for question in self.questions: + if question['type'] == 'free-text': + # Free text + widget_label = widgets.HTML(value="<b>{}</b>".format(question['label'])) + widget_value = widgets.Textarea(**self.textarea_args) + + elif question['type'] == 'scale': + # Toggle Buttons + widget_label = widgets.HTML(value="<b>{}</b>".format(question['label'])) + widget_value = widgets.ToggleButtons(**self.toggle_args) + + self.widgets_VBoxes.append(widgets.VBox([widget_label, widget_value])) + self.widgets_container[question['id']] = widget_value + + # Button + self.btn_submit = widgets.Button(description=self.feedback_text['send'], disabled=False, + style={'button_color': rwth_colors['rwth:green-50'], 'margin': '10px'}, icon='', + layout=Layout(margin='20px 0 0 0', width='auto')) + self.output = widgets.Output() + + self.btn_submit.on_click(self.on_btn_submit_clicked) + + # Display widgets + display(widgets.VBox(self.widgets_VBoxes), + self.btn_submit, self.output); + + self.update_ui_state() + + def check_submission_status(self): + """ + Check entry submission status + + Returns + ------- + status: {'idle', 'saved_locally', 'mail_sent'}, str + submission status + 'idle', if feedback does not exist in feedback json file + 'saved_locally', if feedback exists but was not sent + 'mail_sent', if feedback was already sent via mail + """ + try: + self.load_json_entries() + for entry in self.entries: + if self.feedback_name == entry['name']: + self.is_saved_locally = True # Note: at least saved_locally, can also be mail_sent, we don't care + return entry['status'] + return 'idle' + except FileNotFoundError: + return 'idle' + + def send_mail(self): + """ + Sends JSON file as attachment of a mail to predefined recipient + + Sets self.is_mail_sent to True if mail was sent successfully. False otherwise. + """ + try: + import smtplib + from email.mime.multipart import MIMEMultipart + from email.mime.base import MIMEBase + from email import encoders + + # create message + msg = MIMEMultipart() + msg['From'] = self.mail_from + msg['To'] = self.mail_to + + if self.mail_subject is not None: + msg['Subject'] = self.mail_subject else: - # set up json entries - entry['name'] = feedback_name - entry['date'] = "{}".format(datetime.datetime.now()) - if 'USER' in os.environ.keys(): - user_name = os.environ['USER'] - else: - user_name = os.environ['HOSTNAME'] # take hostname as fall-back - entry['userhash'] = hashlib.sha256(str.encode(user_name)).hexdigest() - entry['answer'] = {q['id']: v.value for q, v in zip(questions, widgets_values)} - - if not is_send_confirmed: - button.description = feedback_text['confirm_send'] - button.layout.width = 'auto' - is_send_confirmed = True - return - - # dump entries into json file - if not os.path.isfile(feedback_path): - with open(feedback_path, mode='w', encoding='utf-8') as f: - json.dump([], f) - with open(feedback_path, mode='r', encoding='utf-8') as f: - entries = json.load(f) - with open(feedback_path, mode='w', encoding='utf-8') as f: - entries.append(entry) - json.dump(entries, f) - - # send json file as attachment of mail - if mail_to is not None: - try: - import smtplib - from email.mime.multipart import MIMEMultipart - from email.mime.base import MIMEBase - from email import encoders - - # create message - msg = MIMEMultipart() - msg['From'] = mail_from - msg['To'] = mail_to - - if mail_subject is not None: - msg['Subject'] = mail_subject - else: - msg['Subject'] = feedback_name - - # open the file to be sent as attachment - with open(feedback_path, 'rb') as attachment: - # attach file to mail - p = MIMEBase('application', 'octet-stream') - p.set_payload(attachment.read()) - - # encode and add as attachment to message - encoders.encode_base64(p) - p.add_header('Content-Disposition', "attachment; filename= %s" % feedback_path) - msg.attach(p) - - # send mail - s = smtplib.SMTP(mail_smtp_host) - text = msg.as_string() - s.sendmail(mail_from, mail_to, text) - - # close connection - s.quit() - - with output: - print(feedback_text['sent']) - - except ConnectionRefusedError: - # Not connected to the RWTH network - with output: - print(feedback_text['mailfailure']) - - button.disabled = True - - # Set callback to button - button.on_click(on_button_clicked) - - # Display widgets - display(widgets.VBox(widgets_container), - button, output) \ No newline at end of file + msg['Subject'] = self.feedback_name + + # open the file to be sent as attachment + with open(self.feedback_path, 'rb') as attachment: + # attach file to mail + p = MIMEBase('application', 'octet-stream') + p.set_payload(attachment.read()) + + # encode and add as attachment to message + encoders.encode_base64(p) + p.add_header('Content-Disposition', "attachment; filename= %s" % self.feedback_path) + msg.attach(p) + + # send mail + s = smtplib.SMTP(self.mail_smtp_host) + text = msg.as_string() + s.sendmail(self.mail_from, self.mail_to, text) + + # close connection + s.quit() + + self.is_mail_sent = True + + except ConnectionRefusedError: + # Not connected to the RWTH network + with self.output: + print(self.feedback_text['mailfailure']) + + self.is_mail_sent = False + + def on_btn_submit_clicked(self, _): + """ + Submit button onClick method + + Sets current json entry + Calls send_mail method + Sets status of all entries to mail_sent if mail was sent successful. + Otherwise entries are locally saved. + """ + # set up json entries + self.entry['name'] = self.feedback_name + self.entry['date'] = "{}".format(datetime.datetime.now()) + if 'USER' in os.environ.keys(): + user_name = os.environ['USER'] + else: + user_name = os.environ['HOSTNAME'] # take hostname as fall-back + self.entry['userhash'] = hashlib.sha256(str.encode(user_name)).hexdigest() + self.entry['answer'] = {key: w.value for key, w in self.widgets_container.items()} + self.entry['status'] = 'saved_locally' + + # confirm submission if not happened already + if not self.is_send_confirmed: + self.btn_submit.description = self.feedback_text['confirm_send'] + self.btn_submit.layout.width = 'auto' + self.is_send_confirmed = True + return + + # dump entries into json file + # append only if not entry does not already exist in json file + # TODO - Editing locally saved submission is not possible. Change? + self.load_json_entries() + if not self.is_saved_locally: + self.entries.append(self.entry) + self.save_json_entries() + + # try to send json file as attachment of mail + if self.mail_to is not None: + self.send_mail() + + # open and set statuses to mail_sent if mail is successfully sent + if self.is_mail_sent: + self.load_json_entries() + + for entry in self.entries: + entry['status'] = 'mail_sent' + + self.save_json_entries() + + self.update_ui_state() + + def save_json_entries(self): + """ + Save JSON entries + + Dumps self.entries into existing file + """ + with open(self.feedback_path, mode='w', encoding='utf-8') as f: + json.dump(self.entries, f) + + def load_json_entries(self): + """ + Load JSON entries + Creates a new file if non-existent + + Entries are loaded into self.entries + """ + # create JSON file if it does not exist + if not os.path.isfile(self.feedback_path): + with open(self.feedback_path, mode='w', encoding='utf-8') as f: + json.dump([], f) + + # load JSON file to self.entries + with open(self.feedback_path, mode='r', encoding='utf-8') as f: + self.entries = json.load(f) + + def update_ui_state(self): + """ + Updates UI state according to feedback submission status. + + Calls either: + ui_state_idle, ui_state_saved_locally or ui_state_mail_sent + according to assignments in self.feedback_status + """ + self.status = self.check_submission_status() + self.feedback_status[self.status]() + + def update_btn_state(self): + """ + Updates button UI state + + Used for confirmation requirement for submitting. + """ + if not self.is_send_confirmed: + self.btn_submit.description = self.feedback_text['confirm_send'] + else: + self.btn_submit.description = self.feedback_text['send'] + + def ui_state_idle(self): + """ + Sets UI state to idle + # all free-text and scales are left untouched + # submit button enabled + """ + pass + + def ui_state_saved_locally(self): + """ + Sets UI state to saved_locally + + All free-text and scales filled with and set to locally saved answers + Submit button is enabled + """ + # load JSON entries + self.load_json_entries() + + # get existing entry + for entry in self.entries: + if self.feedback_name == entry['name']: + self.entry = entry + + # set widgets values to locally saved answers + try: + for key, w in self.widgets_container.items(): + w.value = self.entry['answer'][key] + except KeyError: + with self.output: + print('Something went wrong! Contact notebook provider.') + + def ui_state_mail_sent(self): + """ + Sets UI state to mail_sent + + All widgets are filled with locally saved answers + All widgets and submit button are disabled + """ + # call saved_locally state for filling widgets with saved answers + self.ui_state_saved_locally() + + # disable all widgets + for w in self.widgets_container.values(): + w.disabled = True + + # disable button and change description + self.btn_submit.disabled = True + self.btn_submit.description = self.feedback_text['sent']