Refactor email reminder code
authorCharlie Wang <s455wang@csclub.uwaterloo.ca>
Sat, 4 Feb 2017 15:56:14 +0000 (10:56 -0500)
committerCharlie Wang <s455wang@csclub.uwaterloo.ca>
Sat, 4 Feb 2017 17:21:05 +0000 (12:21 -0500)
- Pull out email generation into a new module so that the email
message itself can be tested.
- Add a "backdoor" for the email login screen that writes emails to
text files under test_emails/ instead of sending them.
- Add a GNU expect script to automate email reminder testing using
the above features.

.gitignore
TODO
library/emails.py [new file with mode: 0644]
library/interface/sendemails.py
run_emails_test.exp [new file with mode: 0755]

index 221046f..74bc2d9 100644 (file)
@@ -1,2 +1,3 @@
 *.pyc
 /build-library/
+/test_emails/
diff --git a/TODO b/TODO
index 8389e32..97f00a2 100644 (file)
--- a/TODO
+++ b/TODO
@@ -36,6 +36,7 @@ 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/library/emails.py b/library/emails.py
new file mode 100644 (file)
index 0000000..23acfdb
--- /dev/null
@@ -0,0 +1,34 @@
+def format_reminder_email(quest_id: str,
+                          days_signed_out: int,
+                          librarian_name: str,
+                          book_name) -> str:
+    """
+    Formats an email as a plain string for sending out email reminders
+    for signed out books.
+
+    Example: _send_email("s455wang", "2017-02-04", 30, "csfmurph", "How to Design Programs")
+    """
+
+    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.
+
+Thanks,
+
+{}
+Computer Science Club | University of Waterloo
+librarian@csclub.uwaterloo.ca""".format(
+        quest_id,
+        book_name,
+        days_signed_out,
+        librarian_name
+    )
+
+    email_message_subject = "Overdue book: {}".format(book_name)
+    email_message = "Subject: {}\n\n{}".format(email_message_subject,
+                                               email_message_body)
+    return email_message
+
index 2356f16..bebe820 100644 (file)
@@ -8,7 +8,7 @@ import argparse
 import sys
 
 #Import datetime and time to check dates
-from datetime import date
+from datetime import datetime
 import time
 
 #Import getpass for password input
@@ -21,128 +21,146 @@ 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, catch_error_with, error_form
+from library.emails import format_reminder_email
+
+# email testing folder creation
+import os
 
 #Constants
 DEFAULT_DAY_VALUE = 21
 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"]
+    caption = "Enter the max number of days a book can be signed out for"
+    blabel = "Enter"
+    labels = ["Days"]
 
-  def _return_values(self):
-    return self.entries[0].value
+    def _return_values(self):
+        return self.entries[0].value
 
 
 class NameForm(FormWindow):
-  caption = "Enter the name you want in the signature line for the email"
-  blabel = "Enter"
-  labels = ["Name"]
-  
-  def _return_values(self):
-    if self.entries[0].value is "":
-      return "Librarian"
-    else:
-      return self.entries[0].value
+    caption = "Enter the name you want in the signature line for the email"
+    blabel = "Enter"
+    labels = ["Name"]
+
+    def _return_values(self):
+        if self.entries[0].value is "":
+            return "Librarian"
+        else:
+            return self.entries[0].value
+
 
 #Private functions
-def _send_email(quest_id: str, signed_out_date: str, max_days_can_be_signed_out:int, librarianName: str, book_name_list) -> None:
+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:
     """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"""
-
-    email_address = quest_id + "@csclub.uwaterloo.ca"
-    book_name = "".join(book_name_list)
-
-    #Determine the days the book has been signed out
-    date_tokens = signed_out_date.split("-")
-    date_signed_out = date(int(date_tokens[0]), int(date_tokens[1]), int(date_tokens[2]))
-    days_signed_out = date.fromtimestamp(time.time()) - date_signed_out
-  
-    if days_signed_out.days > max_days_can_be_signed_out:
-        email_message_body = ("Hi " + quest_id + ",\n\n" +  "Our records indicate that you "
-                             "have had the book " + book_name + " signed out for " + str(str(days_signed_out).split(" ")[:1])[2:-2] + 
-                              " days.\n\n" +  "Please return the book to the CS Club office "
-                              "(MC 3036) at your earliest convenience.\n\n" + "Thanks,\n\n" + 
-                               librarianName + "\n" + "Computer Science Club | University of Waterloo\n" 
-                               "librarian@csclub.uwaterloo.ca")
-           
-        email_message_subject = "Overdue book: {}".format(book_name)
-        email_message = "Subject: {}\n\n{}".format(email_message_subject, email_message_body)
-  
-        server.sendmail("librarian@csclub.uwaterloo.ca", email_address, email_message)
-  
-#Public functions    
+    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.
+    """
+
+    # Determine the days the book has been signed out
+    d = datetime.strptime(signed_out_date, "%Y-%m-%d")
+    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)
+        email_message = format_reminder_email(quest_id, days_signed_out, librarian_name, book_name)
+        if testing:
+            os.makedirs("test_emails", exist_ok=True)
+            with open("test_emails/{}_{}.txt".format(quest_id, book_name), 'w') as f:
+                print(email_message, file=f)
+        else:
+            server.sendmail("librarian@csclub.uwaterloo.ca", email_address, email_message)
+
+
+#Public functions
 @permissions.check_permissions(permissions.PERMISSION_LIBCOM)
-@catch_error_with(lambda w, hb, *args : (w, hb, None))
+@catch_error_with(lambda w, hb, *args: (w, hb, None))
 def sendemails_procedure(w, hb, cy, cx, mx):
-  """Procedure to send emails to those with overdue books
+    """Procedure to send emails to those with overdue books
 
     w:      ncurses window for the routine
     cy,cx:  centre coordinates of the screen
     mx:     max width of screen
     """
-  
-  #Get the max days a book can be signed out for
-  step1 = DaysForm(w,hb,width=mx-20)
-  (r,c)=w.getmaxyx()
-  w.mvwin(cy-r//2,cx-c//2)
-  days = step1.event_loop()
-  step1.clear()
-
-  #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
-  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()
-
-  #Set up email
-  global server
-  server = smtplib.SMTP_SSL("mail.csclub.uwaterloo.ca", 465)
-
-  #Attempt to login 3 times
-  loginAttempts = 0
-  while loginAttempts < MAX_LOGIN_ATTEMPTS:
-
-    #Get the user's login info to login to the mail server
-    step3 = LoginForm(w,hb,width=mx-20)
-    (r,c)=w.getmaxyx()
-    w.mvwin(cy-r//2,cx-c//2)
-    loginInfo = step3.event_loop()
-    step3.clear()
-
-    csclubID = loginInfo[0]
-    csclubPwd = loginInfo[1]
-  
-    try:
-      server.login(csclubID, csclubPwd)
-      break
-    except smtplib.SMTPAuthenticationError:
-      loginAttempts += 1
-
-  #Check to see if login failed
-  if loginAttempts >= MAX_LOGIN_ATTEMPTS:
-     return ""
-  
-  #Get the books that are signed out
-  signed_out_books = db.get_checkedout_books()
-
-  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)
-  #Exit from the email server    
-  server.quit()
-
-  return ""
-
 
+    #Get the max days a book can be signed out for
+    step1 = DaysForm(w, hb, width=mx - 20)
+    (r, c) = w.getmaxyx()
+    w.mvwin(cy - r // 2, cx - c // 2)
+    days = step1.event_loop()
+    step1.clear()
+
+    #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
+    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()
+
+    #Set up email
+    global server
+    server = smtplib.SMTP_SSL("mail.csclub.uwaterloo.ca", 465)
+
+    # don't send out emails if this is true
+    testing = False
+
+    #Attempt to login 3 times
+    loginAttempts = 0
+    while loginAttempts < MAX_LOGIN_ATTEMPTS:
+
+        #Get the user's login info to login to the mail server
+        step3 = LoginForm(w, hb, width=mx - 20)
+        (r, c) = w.getmaxyx()
+        w.mvwin(cy - r // 2, cx - c // 2)
+        loginInfo = step3.event_loop()
+        step3.clear()
+
+        csclubID = loginInfo[0]
+        csclubPwd = loginInfo[1]
+
+        if csclubID == "TESTING12345":
+            testing = True
+            break
+
+        try:
+            server.login(csclubID, csclubPwd)
+            break
+        except smtplib.SMTPAuthenticationError:
+            loginAttempts += 1
+
+    #Check to see if login failed
+    if loginAttempts >= MAX_LOGIN_ATTEMPTS:
+        return ""
+
+    #Get the books that are signed out
+    signed_out_books = db.get_checkedout_books()
+
+    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)
+
+    #Exit from the email server
+    server.quit()
+
+    return ""
diff --git a/run_emails_test.exp b/run_emails_test.exp
new file mode 100755 (executable)
index 0000000..63f5447
--- /dev/null
@@ -0,0 +1,92 @@
+#!/usr/bin/expect -f
+
+# Run this to test out email sending. Writes emails to text files
+# under the "test_emails" directory.
+
+# To do this manually, use the UI to send emails normally but put
+# "TESTING12345" as the username. The password is thrown away.
+
+#
+# This Expect script was generated by autoexpect on Sat Feb  4 11:41:53 2017
+# Expect and autoexpect were both written by Don Libes, NIST.
+#
+# Note that autoexpect does not guarantee a working script.  It
+# necessarily has to guess about certain things.  Two reasons a script
+# might fail are:
+#
+# 1) timing - A surprising number of programs (rn, ksh, zsh, telnet,
+# etc.) and devices discard or ignore keystrokes that arrive "too
+# quickly" after prompts.  If you find your new script hanging up at
+# one spot, try adding a short sleep just before the previous send.
+# Setting "force_conservative" to 1 (see below) makes Expect do this
+# automatically - pausing briefly before sending each character.  This
+# pacifies every program I know of.  The -c flag makes the script do
+# this in the first place.  The -C flag allows you to define a
+# character to toggle this mode off and on.
+
+set force_conservative 0  ;# set to 1 to force conservative mode even if
+                         ;# script wasn't run conservatively originally
+if {$force_conservative} {
+       set send_slow {1 .1}
+       proc send {ignore arg} {
+               sleep .1
+               exp_send -s -- $arg
+       }
+}
+
+#
+# 2) differing output - Some programs produce different output each time
+# they run.  The "date" command is an obvious example.  Another is
+# ftp, if it produces throughput statistics at the end of a file
+# transfer.  If this causes a problem, delete these patterns or replace
+# them with wildcards.  An alternative is to use the -p flag (for
+# "prompt") which makes Expect only look for the last line of output
+# (i.e., the prompt).  The -P flag allows you to define a character to
+# toggle this mode off and on.
+#
+# Read the man page for more info.
+#
+# -Don
+
+
+set timeout 5
+spawn ./librarian
+match_max 100
+send -- "\eOB"
+send -- "\eOB"
+send -- "\eOB"
+send -- "\eOB"
+send -- "\eOB"
+send -- "\eOB"
+send -- "\eOB"
+send -- "\r"
+send -- "0"
+send -- "\7f"
+send -- "1"
+send -- "\r"
+send -- "\r"
+send -- "T"
+send -- "E"
+send -- "S"
+send -- "T"
+send -- "\r"
+send -- "\r"
+send -- "T"
+send -- "E"
+send -- "S"
+send -- "T"
+send -- "I"
+send -- "N"
+send -- "G"
+send -- "1"
+send -- "2"
+send -- "3"
+send -- "4"
+send -- "5"
+send -- "\r"
+send -- "\r"
+send -- "\r"
+send -- "q"
+expect eof
+
+send_user -- "Check the test_emails directory\n"