Quality-of-life improvements to email reminders.
authorPatrick Melanson <pj2melan@csclub.uwaterloo.ca>
Fri, 28 Jul 2017 00:46:13 +0000 (20:46 -0400)
committerPatrick Melanson <pj2melan@csclub.uwaterloo.ca>
Sat, 29 Jul 2017 20:46:41 +0000 (16:46 -0400)
-Tweaked email body wording
-Added To: field
-Added librarian@ as a recipient of reminder emails
-Added a confirmation that emails were sent
-Added ability to cancel email sending (by using cancel button)
-Added a nice confirmation dialog at the end of sending
 reminder emails to keep the librarian's hopes up

TODO
debian/changelog
library/emails.py
library/interface/form.py
library/interface/sendemails.py
run_emails_test.exp

diff --git a/TODO b/TODO
index 97f00a2..8389e32 100644 (file)
--- 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
index 13b901a..3a8196b 100644 (file)
@@ -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 <pj2melan@csclub.uwaterloo.ca>  Sat, 29 Jul 2017 15:12:12 -0400
+
 library (1.0-3stretch0) stretch; urgency=medium
 
   * Package for stretch
index 23acfdb..b5d34fa 100644 (file)
@@ -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
 
index 7dc9d37..1002ba2 100644 (file)
@@ -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)
index bebe820..8304469 100644 (file)
@@ -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 ""
index 63f5447..1c807e4 100755 (executable)
@@ -86,6 +86,7 @@ send -- "5"
 send -- "\r"
 send -- "\r"
 send -- "\r"
+send -- "\r"
 send -- "q"
 expect eof