diff --git a/TODO b/TODO index 97f00a2..8389e32 100644 --- a/TODO +++ b/TODO @@ -36,7 +36,6 @@ Support UTF-8 for everything Search ignores Case (for lowercase search strings) Text entry supports longer string Home and End navigate to top and bottom of catalogue respectively. -Email reminders for signed-out books Support for multiple copies - books will have their book_id written in pencil on inside cover diff --git a/debian/changelog b/debian/changelog index 13b901a..3a8196b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +library (1.0-4stretch0) UNRELEASED; urgency=medium + + * New menu option to send out mass email for overdue books + * Community effort by Connor Murphy, Charlie Wang, Patrick Melanson + + -- Patrick James Melanson Sat, 29 Jul 2017 15:12:12 -0400 + library (1.0-3stretch0) stretch; urgency=medium * Package for stretch diff --git a/library/emails.py b/library/emails.py index 23acfdb..b5d34fa 100644 --- a/library/emails.py +++ b/library/emails.py @@ -8,17 +8,24 @@ def format_reminder_email(quest_id: str, Example: _send_email("s455wang", "2017-02-04", 30, "csfmurph", "How to Design Programs") """ + assert len(quest_id) <= 8 + assert quest_id.isalnum() + assert days_signed_out > 0 + assert librarian_name != "" + assert book_name != "" email_message_body = \ """Hi {}, Our records indicate that you have had the book {} signed out for {} days. -Please return the book to the CS Club office (MC 3036) at your earliest convenience. +If you would like to keep this book checked out, tell us when in the next month you will return this book. -Thanks, +Otherwise, please return the book to the CS Club office (MC 3036) at your earliest convenience. -{} +Thank you for using the CS Club library! + +{} | Librarian Computer Science Club | University of Waterloo librarian@csclub.uwaterloo.ca""".format( quest_id, @@ -28,7 +35,13 @@ librarian@csclub.uwaterloo.ca""".format( ) email_message_subject = "Overdue book: {}".format(book_name) - email_message = "Subject: {}\n\n{}".format(email_message_subject, - email_message_body) + email_message = ( + "To: \"{0}@csclub.uwaterloo.ca\" <{0}@csclub.uwaterloo.ca>\n".format(quest_id) + + "Subject: {}\n".format(email_message_subject) + + "\n" + + email_message_body + ) + assert email_message.replace("\n", "").isprintable(), \ + "Our email should not have characters apart from normal characters and newline" return email_message diff --git a/library/interface/form.py b/library/interface/form.py index 7dc9d37..1002ba2 100644 --- a/library/interface/form.py +++ b/library/interface/form.py @@ -48,7 +48,7 @@ class TextEntry: def gain_focus(self): self.focus = True self._mv_cursor(+len(self.value)) - self.start = max(0,self.cursor-self.width) + self.start = max(0,self.cursor-self.width) self.redraw() def lose_focus(self): @@ -85,7 +85,7 @@ class TextEntry: def _set_cursor(self, new_c): self.cursor = max(0, min(len(self.value), new_c)) - self.start = max(0,self.cursor-self.width+1) + self.start = max(0,self.cursor-self.width+1) self.redraw() # Place the drawn cursor in the correct spot col = self.x + self.cursor - self.start @@ -358,12 +358,13 @@ class CategoryForm(FormWindow): def _return_values(self): return self.entries[0].value -class ErrorForm(FormWindow): - caption = "Error" +class InfoForm(FormWindow): + caption = "Info" blabel = "OK" buttononly = True - def __init__(self,window,helpbar,errortext,width=50): + def __init__(self,caption,window,helpbar,errortext,width=50): self.labels = errortext.split("\n") + self.caption = caption super().__init__(window, helpbar, width=width) def redraw(self): @@ -380,6 +381,16 @@ class ErrorForm(FormWindow): def _mv_focus(self,delta): pass +class ErrorForm(InfoForm): + def __init__(self,window,helpbar,errortext,width=50): + super().__init__("Error", window, helpbar, errortext, width) + +class SuccessForm(InfoForm): + def __init__(self,window,helpbar,errortext,width=50): + super().__init__("Success", window, helpbar, errortext, width) + # Any good news is great news in the world of the librarian. + self.blabel = "Great News!" + def error_form(text, w, hb): width = max([len(l) for l in text.split("\n")]) + 4 child=curses.newwin(1,1) diff --git a/library/interface/sendemails.py b/library/interface/sendemails.py index bebe820..8304469 100644 --- a/library/interface/sendemails.py +++ b/library/interface/sendemails.py @@ -21,7 +21,7 @@ import subprocess from library import permissions from library.exceptions import * import library.database as db -from library.interface.form import FormWindow, BookForm, LoginForm, catch_error_with, error_form +from library.interface.form import FormWindow, BookForm, LoginForm, SuccessForm, catch_error_with, error_form from library.emails import format_reminder_email # email testing folder creation @@ -35,10 +35,17 @@ MAX_LOGIN_ATTEMPTS = 3 class DaysForm(FormWindow): caption = "Enter the max number of days a book can be signed out for" blabel = "Enter" - labels = ["Days"] + labels = ["Days (default " + str(DEFAULT_DAY_VALUE) + ")"] def _return_values(self): - return self.entries[0].value + ret = self.entries[0].value + if ret is "": + return DEFAULT_DAY_VALUE + else: + #If we didn't get valid input, noisily fail + assert ret.isdigit() and int(ret) > 0, \ + "Max signed out days is not positive: " + ret.__repr__() + return int(ret) class NameForm(FormWindow): @@ -50,6 +57,7 @@ class NameForm(FormWindow): if self.entries[0].value is "": return "Librarian" else: + assert self.entries[0].value.isprintable() return self.entries[0].value @@ -58,13 +66,15 @@ def _send_email(quest_id: str, signed_out_date: str, max_days_can_be_signed_out: int, librarian_name: str, - book_name_list, - testing=False) -> None: + book_name: str, + testing=False) -> bool: """Sends an email to quest_id@csclub.uwaterloo.ca if the date the book was signed out exceeds the days it is supposed to be signed out for. Set testing to true to output emails to the current directory, instead of actually sending them. + + Returns whether an email was sent. """ # Determine the days the book has been signed out @@ -72,15 +82,22 @@ def _send_email(quest_id: str, days_signed_out = (d.today() - d).days if days_signed_out > max_days_can_be_signed_out: - email_address = quest_id + "@csclub.uwaterloo.ca" - book_name = "".join(book_name_list) + #Librarian also gets a copy, so that unreplied emails are more apparent. + email_addresses = [quest_id + "@csclub.uwaterloo.ca", + "librarian@csclub.uwaterloo.ca"] email_message = format_reminder_email(quest_id, days_signed_out, librarian_name, book_name) if testing: + assert book_name.isprintable() os.makedirs("test_emails", exist_ok=True) - with open("test_emails/{}_{}.txt".format(quest_id, book_name), 'w') as f: + #Know that book titles such as "FORTRAN/77" exist, sanitize slashes. + filename = ("test_emails/{}_{}.txt" + .format(quest_id, book_name.replace('/', '-'))) + with open(filename, 'w') as f: print(email_message, file=f) else: - server.sendmail("librarian@csclub.uwaterloo.ca", email_address, email_message) + server.sendmail("librarian@csclub.uwaterloo.ca", email_addresses, email_message) + return True + return False #Public functions @@ -100,17 +117,17 @@ def sendemails_procedure(w, hb, cy, cx, mx): w.mvwin(cy - r // 2, cx - c // 2) days = step1.event_loop() step1.clear() + if days == {}: + return "" #User cancelled the dialog box - #Set the days to a default value if the value given is less than 0 - if not isinstance(days, int) or days < 0: - days = DEFAULT_DAY_VALUE - - #Get the name of the librarain + #Get the name of the librarian step2 = NameForm(w, hb, width=mx - 20) (r, c) = w.getmaxyx() w.mvwin(cy - r // 2, cx - c // 2) librarianName = step2.event_loop() step2.clear() + if librarianName == {}: + return "" #User cancelled the dialog box #Set up email global server @@ -149,18 +166,53 @@ def sendemails_procedure(w, hb, cy, cx, mx): #Get the books that are signed out signed_out_books = db.get_checkedout_books() + earliest_checkout = datetime.now() + emails_sent = 0 for data in signed_out_books: quest_id = data["uwid"] date = str(data["date"]).split(" ")[0] book_name_with_spaces = data["title"] - _send_email( - str(quest_id), - str(date), days, str(librarianName), book_name_with_spaces, - testing) + if _send_email( + str(quest_id), + str(date), days, str(librarianName), book_name_with_spaces, + testing): + emails_sent += 1 + earliest_checkout = min(earliest_checkout, + datetime.strptime(date, "%Y-%m-%d")) #Exit from the email server server.quit() + #A success message about what emails we just sent + assert earliest_checkout > datetime(2010,1,1) + time_since_earliest_checkout = '' + days_since_earliest_checkout = (datetime.now().toordinal() + - earliest_checkout.toordinal()) + assert days >= 0 + month_length = 30 #It's wrong because it's an approximation + if days_since_earliest_checkout < month_length: + time_since_earliest_checkout = '{} days ago'.format(days_since_earliest_checkout) + else: + time_since_earliest_checkout = '{:0.1f} months ago'.format(days_since_earliest_checkout / month_length) + + if emails_sent == 0: + success_text = """ +No books are overdue, so all zero reminder emails were vacuously sent successfully. +Either members are (hopefully) good at returning books or members don't take books out.""" + else: + success_text = """ +Automatically sent out {} individual reminder emails, one per overdue book. +The most overdue book was checked out approximately {}.""".format( + emails_sent, + time_since_earliest_checkout + ) + + step4 = SuccessForm(w, hb, success_text, width=mx - 20) + (r, c) = w.getmaxyx() + w.mvwin(cy - r // 2, cx - c // 2) + step4.event_loop() + step4.clear() + return "" diff --git a/run_emails_test.exp b/run_emails_test.exp index 63f5447..1c807e4 100755 --- a/run_emails_test.exp +++ b/run_emails_test.exp @@ -86,6 +86,7 @@ send -- "5" send -- "\r" send -- "\r" send -- "\r" +send -- "\r" send -- "q" expect eof