From 17c3eba63e2cf7b181adfcd18ed5290733c4e7cd Mon Sep 17 00:00:00 2001 From: Matt Maeda Date: Tue, 27 Feb 2018 16:18:50 -0800 Subject: [PATCH] mailroom app with basic logging --- students/mattmaeda/mailroom_app/LICENSE.txt | 6 + students/mattmaeda/mailroom_app/README.txt | 1 + students/mattmaeda/mailroom_app/bin/mailroom | 10 + .../mailroom_app/mailroom/__init__.py | 11 + .../mattmaeda/mailroom_app/mailroom/cli.py | 91 ++++++++ .../mailroom/data/sample_data.json | 1 + .../mattmaeda/mailroom_app/mailroom/model.py | 200 ++++++++++++++++++ .../mailroom_app/mailroom/test/.coveragerc | 3 + .../mailroom_app/mailroom/test/__init__.py | 0 .../mailroom/test/mock_load_file.json | 1 + .../mailroom_app/mailroom/test/test_cli.py | 129 +++++++++++ .../mailroom_app/mailroom/test/test_model.py | 180 ++++++++++++++++ .../mailroom/test/test_output.json | 1 + .../mailroom_app/mailroom/test_output.json | 1 + students/mattmaeda/mailroom_app/setup.py | 35 +++ 15 files changed, 670 insertions(+) create mode 100644 students/mattmaeda/mailroom_app/LICENSE.txt create mode 100644 students/mattmaeda/mailroom_app/README.txt create mode 100755 students/mattmaeda/mailroom_app/bin/mailroom create mode 100644 students/mattmaeda/mailroom_app/mailroom/__init__.py create mode 100644 students/mattmaeda/mailroom_app/mailroom/cli.py create mode 100644 students/mattmaeda/mailroom_app/mailroom/data/sample_data.json create mode 100644 students/mattmaeda/mailroom_app/mailroom/model.py create mode 100644 students/mattmaeda/mailroom_app/mailroom/test/.coveragerc create mode 100644 students/mattmaeda/mailroom_app/mailroom/test/__init__.py create mode 100644 students/mattmaeda/mailroom_app/mailroom/test/mock_load_file.json create mode 100644 students/mattmaeda/mailroom_app/mailroom/test/test_cli.py create mode 100644 students/mattmaeda/mailroom_app/mailroom/test/test_model.py create mode 100644 students/mattmaeda/mailroom_app/mailroom/test/test_output.json create mode 100644 students/mattmaeda/mailroom_app/mailroom/test_output.json create mode 100644 students/mattmaeda/mailroom_app/setup.py diff --git a/students/mattmaeda/mailroom_app/LICENSE.txt b/students/mattmaeda/mailroom_app/LICENSE.txt new file mode 100644 index 00000000..672a59a7 --- /dev/null +++ b/students/mattmaeda/mailroom_app/LICENSE.txt @@ -0,0 +1,6 @@ +License +------- + +Licensed under the Creative Commons Attribution-ShareAlike 4.0 International Public License. + +https://creativecommons.org/licenses/by-sa/4.0/legalcode diff --git a/students/mattmaeda/mailroom_app/README.txt b/students/mattmaeda/mailroom_app/README.txt new file mode 100644 index 00000000..3d0d3e47 --- /dev/null +++ b/students/mattmaeda/mailroom_app/README.txt @@ -0,0 +1 @@ +The mailroom app for python class diff --git a/students/mattmaeda/mailroom_app/bin/mailroom b/students/mattmaeda/mailroom_app/bin/mailroom new file mode 100755 index 00000000..9095417e --- /dev/null +++ b/students/mattmaeda/mailroom_app/bin/mailroom @@ -0,0 +1,10 @@ +#!/usr/bin/env python + +""" +Entry point for the mailroom app +""" + +import mailroom.cli + +if __name__ == "__main__": + mailroom.cli.main() diff --git a/students/mattmaeda/mailroom_app/mailroom/__init__.py b/students/mattmaeda/mailroom_app/mailroom/__init__.py new file mode 100644 index 00000000..63d91f26 --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +""" +mailroom package +""" + +from pathlib import Path + +__version__ = "0.1.1" + +DATA_DIR = Path(__file__).parent / "data" diff --git a/students/mattmaeda/mailroom_app/mailroom/cli.py b/students/mattmaeda/mailroom_app/mailroom/cli.py new file mode 100644 index 00000000..0ac60a7a --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/cli.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +""" +Interface to mailroom package +""" + +from __future__ import print_function + +import sys +import math +import logging +from mailroom import model, DATA_DIR + +logging.getLogger(__name__) + +MENU = """ +Choose an option: + +1. Send Thank You +2. Create Report +3. Quit + +> """ + +DB = model.DonorDB.load_from_file(DATA_DIR / "sample_data.json") + +def get_selection(): + """ Display menu option and get user input """ + logging.info("Get menu selections") + selection = input(MENU) + logging.info("User selected option %s", selection) + return selection.strip() + + +def send_thank_you(): + """ Record a donation and send thank you message """ + while True: + logging.debug("Getting user input for name") + name = input("Enter donor's name > ").strip() + break + + while True: + amount = input("Enter a donation amount > ").strip() + try: + logging.debug("Getting user input for donation amount") + amount = float(amount) + if math.isnan(amount) or math.isinf(amount) or round(amount, 2) == 0.00: + logging.warn("User did not enter a valid amount") + raise ValueError + break + except ValueError: + print("Invalid amount '{}' entered.".format(amount)) + + donor = DB.get_donor(name) + if donor is None: + logging.info("Donor info collected. Adding donor") + donor = DB.add_donor(name) + + donor.add_donation(amount) + print(DB.send_letter(donor)) + + +def print_report(): + """ Print out donor report """ + logging.info("Print out the user report") + print(DB.get_donor_report()) + + +def quit_program(): + """ Exits program """ + logging.info("Exiting program") + sys.exit(0) + + +def main(): + """ Entry point for the entire application """ + menu_dict = {"1": send_thank_you, + "2": print_report, + "3": quit_program} + + while True: + selection = get_selection() + print(selection) + + try: + menu_dict[selection]() + except KeyError: + print("ERROR: Selection '{}' is invalid!".format(selection)) + + +if __name__ == "__main__": + main() diff --git a/students/mattmaeda/mailroom_app/mailroom/data/sample_data.json b/students/mattmaeda/mailroom_app/mailroom/data/sample_data.json new file mode 100644 index 00000000..3f60647e --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/data/sample_data.json @@ -0,0 +1 @@ +{"George Washington":[1],"John Adams":[3],"Thomas Jefferson":[3],"John Quincy Adams":[2],"James Madison":[2]} diff --git a/students/mattmaeda/mailroom_app/mailroom/model.py b/students/mattmaeda/mailroom_app/mailroom/model.py new file mode 100644 index 00000000..04cee09f --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/model.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python +""" +Data model for mailroom package +""" +import json +import logging + +logging.getLogger(__name__) + +THANK_YOU_LETTER = """ +Dear {0:s}, + +Thank you for your donation of ${1:.2f}. +""" + +REPORT_HEADER = "{donor: <50}| {total: <12}| {num: <10}| {avg: <12}" +REPORT_LINE = "{0: <50} ${1: 12.2f}{2: 12d}{3: 14.2f}" + +class Donor(object): + """ + Donor class responsible for handling donor information + """ + _donorname = None + + def __init__(self, donor_name, initial_donations=None): + logging.info("Set up donor '%s'", donor_name) + self.name = donor_name + if initial_donations is None: + logging.info("Donor %s has not initial donations", donor_name) + initial_donations = [] + self.donations = list(initial_donations) + + + @property + def name(self): + """ donor name getter """ + return self._donorname + + + @name.setter + def name(self, donor_name): + """ donor name setter + :param donor_name: donor name + :type: str + """ + self._donorname = donor_name + + + @property + def total_donations(self): + """ donation getter """ + logging.info("Get the total donations for %s", self.name) + return sum(self.donations) + + + @property + def avg_donations(self): + """ get average donations """ + logging.info("Get the average donations for %s", self.name) + return self.total_donations / len(self.donations) + + + @property + def num_donations(self): + """ get number of donations """ + logging.info("Getting the number of donations for %s", self.name) + return len(self.donations) + + + @property + def last_donation(self): + """ get the last donation made by donor """ + logging.info("Get the last donation for %s", self.name) + try: + return self.donations[-1] + except IndexError: + logging.warn("Donor %s has not made any donations", self.name) + return None + + + def add_donation(self, amount): + """ add donation to the list of donations """ + logging.info("Donor %s made a donation of %d", self.name, amount) + self.donations.append(amount) + + +class DonorDB(object): + """ + Database that holds all donor information + """ + + def __init__(self, donors=None): + if donors is None: + donors = {} + self.donor_data = {d.name: d for d in donors} + + + def save_to_file(self, filename): + """ + output donor information to file + """ + output = {k: v.donations for k, v in self.donor_data.items()} + with open(filename, "w") as outfile: + json.dump(output, outfile) + + + @classmethod + def load_from_file(cls, filename): + """ + Load DB from JSON file + """ + with open(filename) as infile: + donors = json.load(infile) + + return cls([Donor(*d) for d in donors.items()]) + + + @property + def donors(self): + """ + get donation values + """ + return self.donor_data.values() + + + def get_donor(self, name): + """ + get donor by name + + :param name: name + + :return: donor object; None if not found + :rtype: Donor + """ + logging.info("Getting donor object for donor %s", name) + return self.donor_data.get(name) + + + def add_donor(self, name): + """ + Add new donor to DB + + :param name: donor name + + :return: donor object + :rtype: Donor + """ + logging.info("Adding donor %s", name) + donor = Donor(name) + self.donor_data[name] = donor + return donor + + + @staticmethod + def send_letter(donor): + """ + Generate thank you letter + + :param donor: donor object + + :return: formatted thank you letter + :rtype: String + """ + logging.info("Generating thank you letter for %s", donor.name) + return THANK_YOU_LETTER.format(donor.name, donor.last_donation) + + + def get_donor_report(self): + """ + Generate sorted list of donors from largest donation total to the least + + :return: formatted donation report + :rtype: String + """ + logging.info("Generating the donor report") + donor_dict = {} + + for donor in self.donor_data.values(): + donor_dict.setdefault(donor.total_donations, []).append(donor) + + report = "\n" + report += (REPORT_HEADER.format(donor="Donor Name", + total="Total Given", + num="Num Gifts", + avg="Average Gift")) + report += "\n" + report += "-" * 90 + report += "\n" + + + for amount in reversed(sorted(donor_dict)): + for donor in donor_dict[amount]: + report += (REPORT_LINE.format(donor.name, + donor.total_donations, + donor.num_donations, + donor.avg_donations)) + report += "\n" + + report += "\n\n" + return report diff --git a/students/mattmaeda/mailroom_app/mailroom/test/.coveragerc b/students/mattmaeda/mailroom_app/mailroom/test/.coveragerc new file mode 100644 index 00000000..30f51f60 --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/test/.coveragerc @@ -0,0 +1,3 @@ +[report] +exclude_lines = + if __name__ == .__main__.: diff --git a/students/mattmaeda/mailroom_app/mailroom/test/__init__.py b/students/mattmaeda/mailroom_app/mailroom/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/students/mattmaeda/mailroom_app/mailroom/test/mock_load_file.json b/students/mattmaeda/mailroom_app/mailroom/test/mock_load_file.json new file mode 100644 index 00000000..5d3f16fa --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/test/mock_load_file.json @@ -0,0 +1 @@ +{"George Washington":[1]} diff --git a/students/mattmaeda/mailroom_app/mailroom/test/test_cli.py b/students/mattmaeda/mailroom_app/mailroom/test/test_cli.py new file mode 100644 index 00000000..e680fdef --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/test/test_cli.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +""" +Unit tests for mailroom cli + +pytest --cov=mailroom +""" + +from unittest import mock +from io import StringIO +import pytest +from mailroom import cli + +THANK_YOU_OUTPUT = """ +Dear Matt, + +Thank you for your donation of $100.00. + +""" + +BAD_THANK_YOU_OUTPUT = """Invalid amount 'x' entered. + +Dear Matt, + +Thank you for your donation of $100.00. + +""" + +BAD_THANK_YOU_OUTPUT2 = """Invalid amount '0.001' entered. + +Dear Matt, + +Thank you for your donation of $100.00. + +""" + +TEST_REPORT_OUTPUT = """ +Donor Name | Total Given | Num Gifts | Average Gift +------------------------------------------------------------------------------------------ +Matt $ 100.00 1 100.00 +John Adams $ 3.00 1 3.00 +Thomas Jefferson $ 3.00 1 3.00 +John Quincy Adams $ 2.00 1 2.00 +James Madison $ 2.00 1 2.00 +George Washington $ 1.00 1 1.00 + + + +""" + + +def test_get_selection(): + """ test get selection """ + user_input = ["1"] + with mock.patch("builtins.input", side_effect=user_input): + option = cli.get_selection() + assert option == "1" + + +@mock.patch('sys.stdout', new_callable=StringIO) +def test_send_thank_you(mock_stdout): + """ test send thank you """ + user_input = ["Matt", "100"] + with mock.patch("builtins.input", side_effect=user_input): + cli.send_thank_you() + assert mock_stdout.getvalue() == THANK_YOU_OUTPUT + + +@mock.patch('sys.stdout', new_callable=StringIO) +def test_print_report(mock_stdout): + """ test print output """ + user_input = ["2"] + with mock.patch("builtins.input", side_effect=user_input): + cli.print_report() + assert mock_stdout.getvalue() == TEST_REPORT_OUTPUT + + +def test_quit_program(): + """ test quit program """ + user_input = ["3"] + with pytest.raises(SystemExit) as sys_exit: + cli.quit_program() + assert sys_exit.type == SystemExit + assert sys_exit.value.code == 0 + + +@mock.patch('sys.stdout', new_callable=StringIO) +def test_main(mock_stdout): + """ test main function """ + with pytest.raises(SystemExit) as sys_exit: + user_input = ["3"] + with mock.patch("builtins.input", side_effect=user_input): + cli.main() + assert sys_exit.type == SystemExit + assert sys_exit.value.code == 0 + + +@mock.patch('sys.stdout', new_callable=StringIO) +def test_bad_send_thank_you(mock_stdout): + """ test send thank you """ + user_input = ["Matt", "x", "100"] + with mock.patch("builtins.input", side_effect=user_input): + cli.send_thank_you() + + assert mock_stdout.getvalue() == BAD_THANK_YOU_OUTPUT + + +@mock.patch('sys.stdout', new_callable=StringIO) +def test_another_bad_send_thank_you(mock_stdout): + """ test send thank you """ + user_input = ["Matt", "0.001", "100"] + with mock.patch("builtins.input", side_effect=user_input): + cli.send_thank_you() + + assert mock_stdout.getvalue() == BAD_THANK_YOU_OUTPUT2 + + +@mock.patch('sys.stdout', new_callable=StringIO) +def test_main_bad_selection(mock_stdout): + """ test main bad input """ + user_input = ["4", "3"] + with mock.patch("builtins.input", side_effect=user_input): + with pytest.raises(SystemExit) as sys_exit: + cli.main() + + assert mock_stdout.getvalue() == "4\n" \ + "ERROR: Selection '4' is invalid!\n" \ + "3\n" + assert sys_exit.type == SystemExit + assert sys_exit.value.code == 0 diff --git a/students/mattmaeda/mailroom_app/mailroom/test/test_model.py b/students/mattmaeda/mailroom_app/mailroom/test/test_model.py new file mode 100644 index 00000000..36238bf9 --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/test/test_model.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +""" +Unit tests for mailroom model +""" +import os +from unittest import mock +import pytest +from mailroom import model + +TEST_LETTER = """ +Dear Test User 1, + +Thank you for your donation of $100.00. +""" + +TEST_REPORT = """ +Donor Name | Total Given | Num Gifts | Average Gift +------------------------------------------------------------------------------------------ +Test User 2 $ 300.00 2 150.00 +Test User 1 $ 150.00 2 75.00 + + +""" + +@pytest.fixture +def sample_db(): + """ Loads test db data """ + return model.DonorDB.load_from_file("mock_load_file.json") + + +def test_donor_init_none(): + """ Test donor init """ + donor = model.Donor("Test User") + assert donor.name == "Test User" + + +def test_donor_init(): + """ Test donor init """ + donor = model.Donor("Test User", [100]) + assert donor.name == "Test User" + assert donor.total_donations == 100 + + +def test_donor_name(): + """ test donor name """ + donor = model.Donor("Test User") + assert donor.name == "Test User" + donor.name = "New Test User" + assert donor.name == "New Test User" + + +def test_add_donation(): + """ test add donation """ + donor = model.Donor("Test User") + donor.add_donation(100) + assert 100 in donor.donations + + +def test_total_donation(): + """ test total donation """ + donor = model.Donor("Test User") + donor.add_donation(100) + donor.add_donation(150) + assert donor.total_donations == 250 + + +def test_avg_donation(): + """ test average donation """ + donor = model.Donor("Test User") + donor.add_donation(50) + donor.add_donation(100) + assert donor.avg_donations == 75 + + +def test_last_donation(): + """ test last donation """ + donor = model.Donor("Test User", [100]) + assert donor.last_donation == 100 + + +def test_last_donation_none(): + """ test last donation no donation """ + donor = model.Donor("Test User") + assert donor.last_donation is None + + +def test_num_donations(): + """ test number of donation """ + donor = model.Donor("Test User") + donor.add_donation(50) + donor.add_donation(100) + assert donor.num_donations == 2 + + +def test_db_init(): + """ test db init """ + database = model.DonorDB() + size = len(database.donors) + assert size == 0 + + +def test_db_init_initial_list(): + """ Test db init with values """ + donor1 = model.Donor("Test User1") + donor2 = model.Donor("Test User2") + donors = [donor1, donor2] + + database = model.DonorDB(donors=donors) + size = len(database.donors) + assert size == 2 + + +def test_db_load_from_file(): + """ test loading from file """ + database = sample_db() + size = len(database.donors) + assert size == 1 + + +def test_db_save_to_file(): + """ test saving to file """ + donor1 = model.Donor("Test User1") + donor2 = model.Donor("Test User2") + donors = [donor1, donor2] + + database = model.DonorDB(donors=donors) + database.save_to_file("test_output.json") + assert os.path.exists("test_output.json") + + + +def test_donors(): + """ Test geting database donors """ + donor1 = model.Donor("Test User1") + donor2 = model.Donor("Test User2") + donors = [donor1, donor2] + + database = model.DonorDB(donors=donors) + donor_list = database.donors + assert "Test User1" in [d.name for d in donor_list] + + +def test_add_donor(): + """ Test geting database donor list """ + donor1 = model.Donor("Test User1") + donor2 = model.Donor("Test User2") + donors = [donor1, donor2] + + database = model.DonorDB(donors=donors) + donor_list = database.donors + assert donor1 in donor_list + + donor3 = database.add_donor("Test User3") + donor_list = database.donors + size = len(donor_list) + assert size == 3 + assert donor3 in donor_list + + +def test_send_letter(): + """ test sending thank you letter """ + donor1 = model.Donor("Test User 1") + donor1.add_donation(100) + database = model.DonorDB() + letter = database.send_letter(donor1) + assert TEST_LETTER == letter + + +def test_donor_report(): + """ test donor report generation """ + donor1 = model.Donor("Test User 1") + donor1.add_donation(100) + donor1.add_donation(50) + donor2 = model.Donor("Test User 2") + donor2.add_donation(250) + donor2.add_donation(50) + database = model.DonorDB(donors=[donor1, donor2]) + report = database.get_donor_report() + + assert TEST_REPORT == report diff --git a/students/mattmaeda/mailroom_app/mailroom/test/test_output.json b/students/mattmaeda/mailroom_app/mailroom/test/test_output.json new file mode 100644 index 00000000..d18864b8 --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/test/test_output.json @@ -0,0 +1 @@ +{"Test User1": [], "Test User2": []} \ No newline at end of file diff --git a/students/mattmaeda/mailroom_app/mailroom/test_output.json b/students/mattmaeda/mailroom_app/mailroom/test_output.json new file mode 100644 index 00000000..d18864b8 --- /dev/null +++ b/students/mattmaeda/mailroom_app/mailroom/test_output.json @@ -0,0 +1 @@ +{"Test User1": [], "Test User2": []} \ No newline at end of file diff --git a/students/mattmaeda/mailroom_app/setup.py b/students/mattmaeda/mailroom_app/setup.py new file mode 100644 index 00000000..b93a7bca --- /dev/null +++ b/students/mattmaeda/mailroom_app/setup.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +""" + +setup.py for mailroom app + +""" + +import os + +from setuptools import setup + +def get_version(): + """ + Reads and returns the version string the from package __init__ + """ + with open(os.path.join("mailroom", "__init__.py")) as init: + for line in init: + parts = line.strip().split("=") + if parts[0].strip() == "__version__": + return parts[-1].strip().strip("'").strip('"') + return None + +setup( + name="mailroom", + version=get_version(), + author="Matt Maeda", + author_email="matt@casamaeda.com", + packages=['mailroom', + 'mailroom/test'], + scripts=['bin/mailroom'], + package_data={"mailroom": ["data/sample_data.json"]}, + license="LICENSE.txt", + description="Mailroom app for python class", + long_description=open("README.txt").read() +)