class Individual(js.JsonSaveable):
    name = js.String()
    donations = js.List()

    def __init__(self, name, donations):
        self.name = name
        self.donations = donations

    def add_donation(self, donation):
        self.donations.append(donation)

    def number_donations(self):
        return int(len(self.donations))

    def sum_donations(self):
        return sum(self.donations)

    def avg_donations(self):
        return self.sum_donations() / \
               self.number_donations()

    def last_donation(self):
        return self.donations[-1]

    def json_format(self):
        setattr(self, 'donor_name', self.name)
        setattr(self, 'donor_donations', self.donations)
        return self.to_json_compat()

    @property
    def thank_you(self):
        """Add a donation to a donors records and print a report."""
        return (
            'Thank you so much for the generous gift of ${0:.2f}, {1}!'.format(
                self.donations[-1], self.name))
class OtherSaveable(js.JsonSaveable):

    foo = js.String()
    bar = js.Int()

    def __init__(self, foo, bar):
        self.foo = foo
        self.bar = bar
Example #3
0
class Donor(js.JsonSaveable):

    _name = js.String()
    _donations = js.List()

    def __init__(self, name, donations=None):
        self._name = name
        if donations is None:
            self._donations = []
        else:
            self._donations = donations

    def __str__(self):
        return f"{self._name} donated ${sum(self._donations):,.2f}"

    def __repr__(self):
        return f"{self._name} donated ${sum(self._donations):,.2f}"

    @property
    def name(self):
        return self._name

    @property
    def donations(self):
        return self._donations

    @property
    def total(self):
        return sum(self._donations)

    @property
    def num_donation(self):
        return len(self._donations)

    @property
    def avg_donation(self):
        return sum(self._donations) / len(self._donations)

    def new_donation(self, donation):
        self._donations.append(donation)

    def __eq__(self, other):
        return sum(self._donations) == sum(other._donations)

    def __ne__(self, other):
        return not sum(self._donations) == sum(other._donations)

    def __lt__(self, other):
        return sum(self._donations) < sum(other._donations)

    def __gt__(self, other):
        return not sum(self._donations) < sum(other._donations)

    def __le__(self, other):
        return sum(self._donations) >= sum(other._donations)

    def __ge__(self, other):
        return not sum(self._donations) >= sum(other._donations)
Example #4
0
class Donor(js.JsonSaveable):
    name = js.String()
    donations = js.List()

    def __init__(self, name, donations=None):
        if not name:
            raise ValueError("Donor name can not be empty")
        self.name = name
        if donations:
            self.donations = donations
        else:
            self.donations = []

    @property
    def first_name(self):
        name_split = self.name.split()
        if len(name_split) >= 1:
            return name_split[0]

    @property
    def last_name(self):
        name_split = self.name.split()
        if len(name_split) == 1:
            return ''
        else:
            return ''.join(name_split[1:])

    @property
    def donor_donations(self):
        """
        Returns list of donor donations
        :return: list of donor donations
        """
        return self.donations

    @property
    def donor_donations_sum(self):
        """
        Returns sum of all donor donations
        :return: donor latest donation
        """
        return sum(self.donations)

    @property
    def latest_donation(self):
        """
        Returns donor latest donation
        :return: donor latest donation
        """
        if self.donations:
            return self.donations[-1]

    def add_donation(self, amount):
        """
        Adds donation to donor donations
        :return:
        """
        if float(amount) <= 0:
            raise ValueError("donation amount can not be negative")
        self.donations.append(float(amount))

    def generate_letter(self):
        """ Generate letter for donor """
        return "Dear {},\n \nThank you for your generous donation {}.\n \n\n\t\tSincerely, \n\t\tLocal Charity". \
            format(self.name, self.latest_donation)
Example #5
0
class SingleDonor(js.JsonSaveable):
    """Provide a class for a single donor."""

    _donations = js.List()
    _name = js.String()

    def __init__(self, _name, _donations):
        """Instantiate a SingleDonor class object."""
        self._name = _name
        if isinstance(_donations, list):
            self._donations = _donations
        elif isinstance(_donations, tuple):
            self._donations = list(_donations)
        else:
            self._donations = [_donations]

    @property
    def name(self):
        """Provide a getter method for the name property."""
        return self._name

    @property
    def donations(self):
        """Provide a getter method for the donations property."""
        return self._donations

    def sort_by_total(self):
        """Provide a sort_key for sorting by total donations."""
        return sum(self._donations)

    def sort_by_name(self):
        """Provide a sort_key for sorting by name."""
        return self._name

    def __str__(self):
        """Return self._name."""
        return self._name

    def __repr__(self):
        """Return SingleDonor("Name", [donations])."""
        if len(self._donations) == 1:
            return 'SingleDonor("{}", {})'.format(self._name,
                                                  self._donations[0])
        else:
            return 'SingleDonor("{}", {})'.format(self._name, self._donations)

    def __eq__(self, other):
        """Return True if names and donations are the same."""
        return (self._name, self._donations) == (other.name, other.donations)

    def __lt__(self, other):
        """Provide __lt__ method used in sorting somehow, I guess."""
        return ((self._name, self._donations) < (other.name, other.donations))

    def add_donation(self, amount):
        """Add a donation."""
        self._donations.append(amount)

    def get_last_donation(self):
        """Return the last donation."""
        return self._donations[-1]

    # def challenge(self,
    #               factor,
    #               min_donation,
    #               max_donation,
    #               projection):
    #     """Return a SingleDonor class object or the projected contribution.
    #
    #     Either multiply all donations by the factor, or
    #     multiply only those donations which are above min_donation or
    #     below max_donation, if any of these parameters is provided,
    #     while the remaining donations remain unchanged.
    #     If the projection is True, return the projected contribution.
    #     """
    #     # Several safeguards
    #     if type(factor) is str or factor <= 1:
    #         raise ValueError("Factor must be a number > 1")
    #     elif type(min_donation) is str or type(max_donation) is str:
    #         raise ValueError("Input must be a number")
    #     elif min_donation is not None and max_donation is not None:
    #         raise ValueError("Min and max must not be both defined")
    #
    #     # Helper function.
    #     def subject_to_increase(x):
    #         """Return True if x above min /below max or if min/max undefined."""
    #         if min_donation is not None:
    #             return x > min_donation
    #         elif max_donation is not None:
    #             return x < max_donation
    #         else:
    #             return True
    #
    #     # The only reason for the following ugly construct is because
    #     # I couldn't imagine how to structure my solution to use map/filter
    #     some_donations = list(filter(subject_to_increase, self.donations))
    #     updated_donations = list(map(lambda x: x * factor
    #                                  if x in some_donations
    #                                  else x,
    #                                  self.donations
    #                                  )
    #                              )
    #
    #     # The following does the same as above but looks much clearer
    #     # updated_donations = [x * factor
    #     #                      if subject_to_increase(x)
    #     #                      else x
    #     #                      for x in self.donations
    #     #                      ]
    #
    #     # projected contribution = increased donationed minus old donations
    #     if projection:
    #         return sum(updated_donations) - sum(self.donations)
    #     else:
    #         return SingleDonor(self.name, updated_donations)

    def multiplier_factory(self, factor, min_donation, max_donation):
        """Create the multiplier function for use in challenge method.

           Args:
                factor (float): the multiplier to be locked in
                                the return function
                min_donation (float or None): a condition to be locked in
                max_donation (None or float): a condition to be locked in
            Returns:
                  a function which will multiply its argument by factor
                  subject to conditions.
        """
        # Several safeguards
        if type(factor) is str or factor <= 1:
            raise ValueError("Factor must be a number > 1")
        elif type(min_donation) is str or type(max_donation) is str:
            raise ValueError("Input must be a number")
        elif min_donation is not None and max_donation is not None:
            raise ValueError("Min and max must not be both defined")

        def func(x):
            def subject_to_increase(x):
                """Decide if the donation (i.e. x) must be increased."""
                if min_donation is not None:
                    return x > min_donation
                elif max_donation is not None:
                    return x < max_donation
                else:
                    return True

            if subject_to_increase(x):
                return factor * x
            else:
                return x

        return func

    def challenge(self, factor, min_donation, max_donation, projection):
        """Return an updated SingleDonor object or a projected contribution."""
        multiplier = self.multiplier_factory(factor, min_donation,
                                             max_donation)
        updated_donations = list(map(multiplier, self.donations))

        # projected contribution = increased donations minus old donations
        if projection:
            return sum(updated_donations) - sum(self.donations)
        else:
            return SingleDonor(self.name, updated_donations)
Example #6
0
class Donor(js.JsonSaveable):
    """donor giving to organization"""
    id = js.Int()
    firstname = js.String()
    lastname = js.String()
    email = js.String()
    _donations = js.List()
    _donation_id = js.Int()

    def __init__(self, id, firstname=None, lastname=None, email=None):
        """args:
            id (int): identification for donor.  Will try to force to int when
                initiated or raise error.
            firstname (str, optional): string representing given name
            lastnamt (str, optional): string representing surname

            _donations (list): contains Donation objects from donor
            _donation_id (int): tracks indentification for donations"""
        try:
            self.id = int(id)
        except ValueError:
            raise ValueError('id input should be interpreted as integer')
        self.firstname = firstname
        self.lastname = lastname
        self.email = email
        self._donations = []
        self._donation_id = 1

    def donation_total(self):
        """returns the total amount the donor has donated"""
        if self.donations:
            print(self.donations)
            return sum([i.amount for i in self.donations])
        else:
            return 0

    def add_donation(self, amount, date=datetime.datetime.utcnow()):
        """adds donation for user"""
        self._donations.append(Donation(amount=amount, date=date, id=self._donation_id))
        self._donation_id += 1

    def donation_count(self):
        """returns count of donations"""
        return len(self._donations)

    @property
    def donations(self):
        """returns donations"""
        return self._donations

    @property
    def fullname(self):
        """returns combine first and last name"""
        return " ".join([self.firstname, self.lastname])

    def summarize_donor(self):
        """provides summary tuple of donor"""
        return (self.id, self.fullname, self.donation_total(), self.donation_count(), self.donation_total()/self.donation_count())

    def __repr__(self):
            return str(self.to_json_compat())
Example #7
0
class DonationController(js.JsonSaveable):
    """organization controller for donations"""
    name = js.String()
    donors = js.Dict()

    def __init__(self, name, donors=None):
        self.name = name
        if donors:
            self.donors = donors
        else:
            self.donors = {}

    def find_donor(self, donor: Donor):
        """searches through donor list and returns donor
        returns none if not found.  Setup to first check for donor id
        if this fails, assumes integer and will check for that"""
        # TODO: refactor and add int method to donor to allow int(donor)
        # to return same as donor.id
        try:
            return self.donors.get(donor.id)
        except AttributeError:
            return self.donors.get(int(donor))

    def create_donor(self, donor: Donor):
        """creates donor in donation controller.  accepts donor object to 
        register donor.  
        args:
            donor: donor object to register with controller

            """
        if self.find_donor(donor):
            raise KeyError('donor already exists')
        if isinstance(donor, Donor):
            try:
                self.donors[donor.id] = donor
                return donor.id
            except AttributeError:
                raise AttributeError('Donor object needs id')

    def build_donor_from_name(self, firstname, lastname):
        """interface to build donor from just name and automatically assign id"""
        self.create_donor(
            Donor(id=self.next_id, firstname=firstname, lastname=lastname))

    def get_total_donations(self):
        """returns total donations in controller"""
        return sum([
            k.donation_total() for i, k in self.donors.items() if k.donations
        ])

    def create_donation(self, donor, amount, date=datetime.datetime.utcnow()):
        """creates donation in input donor"""

        if self.find_donor(donor) is None:
            raise IndexError('Donor does not exist')
        self.find_donor(donor).add_donation(amount)

    @property
    def next_id(self):
        """returns the next avaliable int id from list
        used to asign donor ids"""
        donor_id_list = {i for i in self.donors}
        if self.donors:
            max_id = max(donor_id_list)
        else:
            max_id = 1

        # create range of ints
        non_used_ids = set(range(1, max_id + 1)) - donor_id_list
        if non_used_ids:
            return min(non_used_ids)
        else:
            return max_id + 1

    def display_donors(self):
        """displays a list of donors in printed format"""
        donor_list = [
            ":".join([str(donor_id), donor.fullname])
            for donor_id, donor in self.donors.items()
        ]
        print("\n".join(donor_list))

    def donor_report(self):
        """handles process for main screens report selection

        If the user (you) selected “Create a Report”, print a list of your donors,
        sorted by total historical donation amount.
        Include Donor Name, total donated, number of donations and average
        donation amount as values in each row. You do not need to print out all
        their donations, just the summary info.
        Using string formatting, format the output rows as nicely as possible.
        The end result should be tabular (values in each column should align
        with those above and below)
        After printing this report, return to the original prompt.
        At any point, the user should be able to quit their current task and
        return to the original prompt.
        From the original prompt, the user should be able to quit the script
        cleanly.
        Your report should look something like this:
        Donor Name                | Total Given | Num Gifts | Average Gift
        ------------------------------------------------------------------
        William Gates, III         $  653784.49           2  $   326892.24
        Mark Zuckerberg            $   16396.10           3  $     5465.37
        Jeff Bezos                 $     877.33           1  $      877.33
        Paul Allen                 $     708.42           3  $      236.14
        """
        print(f"{'Donor Name':<26}|{'Total Given':^15}|"
              f"{'Num Gifts':^11}|{'Average Gift':^15}")
        print('-' * 70)
        donor_stats = [
            donor.summarize_donor() for id, donor in self.donors.items()
        ]
        donor_stats.sort(key=lambda tup: tup[2], reverse=True)
        for summary in donor_stats:
            print(f"{summary[1]:<26} ${summary[2]/100:>13.2f}  "
                  f"{summary[3]:>10}  ${summary[4]/100:>14.2f}")

    def send_letters_to_everyone(
        self, thank_you_directory=Path('/mailroom_thankyou_letters')):
        """creates thank yous for all donors and sends out"""
        # iterate through donors and donations to send thank yous
        for id, donor in self.donors.items():
            file_name = "".join([str(id), '.txt'])
            full_path = thank_you_directory / file_name
            donor_info = donor.summarize_donor()
            thank_you_text = self.create_donation_thank_you(
                donor=donor, amount=donor_info[2])
            try:
                print(full_path)
                with open(full_path, 'w') as f:
                    f.write(thank_you_text)
            except FileNotFoundError:
                print(
                    'Mailroom thank you directory not found.  Please create this directory first.'
                )
                break
            else:
                print(
                    f'Thank you letter for {id} created in "{thank_you_directory}"'
                )

    def create_donation_thank_you(self, donor, amount):
        """prints thank you message to terminal for donation"""
        return f"""Dear {donor.fullname},

            Thank you for your very kind donation of ${amount:.2f}.

            It will be put to very good use.

                        Sincerely,
                            -The Team"""

    def challenge(self, factor, min_donation=0, max_donation=1e9):
        """increases donations due to nice donor
        returns copy of donation controller with multiplied input"""

        if factor < 1:
            raise ValueError(
                'Donors are not allowed to take $ from our cause.  Please consider factor of 10 ;)'
            )
        # copy generated to avoid messing with old database
        new_db = copy.deepcopy(self)
        mod_func = modifying_fun_generator(factor)
        mod_filter = filter_fun_generator(min_donation=min_donation,
                                          max_donation=max_donation)
        # seperate list made to simplify code as we need to regenerate dict
        donor_list = map(mod_func, filter(mod_filter, new_db.donors.values()))
        # rebuilds donors dict
        new_db.donors = {i.id: i for i in donor_list}
        return new_db

    def project_donation(self, factor, min_donation=0, max_donation=1e9):
        """provides projected donation amount assuming donor matches all donations"""
        return self.challenge(factor=factor,
                              min_donation=min_donation,
                              max_donation=max_donation).get_total_donations()

    def save(self, filename):
        """saves donation database"""

        with open(filename, 'w') as fp:
            json.dump(self.to_json_compat(), fp)
        return self.to_json_compat()

    @classmethod
    def load(cls, filename):
        """rebuilds database from file"""
        try:
            with open(filename, 'r') as fp:
                data = json.load(fp)
            return cls.from_json_dict(data)
        except FileNotFoundError:
            """creates new donation controller when file not found"""
            return cls(filename)

    def __eq__(self, other):
        return self.to_json_compat() == other.to_json_compat()

    def __repr__(self):
        return str(self.to_json_compat())
Example #8
0
class NoInit(js.JsonSaveable):
    x = js.Int()
    y = js.String()
Example #9
0
class Donor(js.JsonSaveable):
    _donation = js.List()
    _name = js.String()

    def __init__(self, name, donation=None):
        if name == '':
            self._name = 'Anonymous'
        else:
            self._name = name
        if donation is None:
            self._donation = []
        elif isinstance(donation, (int, float)):
            self._donation = [donation]
        else:
            self._donation = donation

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        self._name = str(name)

    @property
    def donation(self):
        return self._donation

    @donation.setter
    def donation(self, donation):
        if type(donation) == list:
            self._donation = donation
        else:
            raise TypeError("Donation has to be a list of int")

    def add_donation(self, donation_amount):
        if donation_amount >= 0:
            self._donation.append(donation_amount)
        else:
            raise ValueError("Donation amount must be greater than 0.")

    def __repr__(self):
        return "Donor({} : {})".format(self._name, self._donation)

    def __str__(self):
        return "Donor: {} donated {}\n".format(self._name, self._donation)

    def donation_occurrences(self):
        return len(self._donation)

    def total_donation_amount(self):
        return sum(self._donation)

    def average_total_donor_amount(self):
        try:
            return self.total_donation_amount() / self.donation_occurrences()
        except ValueError:
            return self._donation

    def stats(self):
        return [
            self.total_donation_amount(),
            self.donation_occurrences(),
            self.average_total_donor_amount()
        ]
Example #10
0
class Donor(js.JsonSaveable):
    _name = js.String()
    _list_donations = js.List()
    _donation_count = js.Int()
    _amount = js.Float()

    def __init__(self, name, list_donations):
        self._name = name
        self._list_donations = list_donations
        self._donation_count = len(list_donations)
        self._amount = sum(list_donations)

    @property
    def name(self):
        return self._name

    @property
    def amount(self):
        return self._amount

    @amount.setter
    def amount(self, amount):
        self._amount = amount

    def add(self, donation_amount):
        self._amount += donation_amount
        self._donation_count += 1
        self._list_donations.append(donation_amount)

    @property
    def donation_count(self):
        return self._donation_count

    @property
    def average(self):
        return self._amount / self._donation_count

    def get_letter_text(self, name, amount):
        msg = []
        msg.append('Dear {},'.format(name))
        msg.append(
            '\n\n\tThank you for your very kind donation of ${:.2f}.'.format(
                amount))
        msg.append('\n\n\tIt will be put to very good use.')
        msg.append('\n\n\t\t\t\tSincerely,')
        msg.append('\n\t\t\t\t-The Team\n')
        return "".join(msg)

    def challenge(self, factor, min_donation=None, max_donation=None):
        """Get the sum of projection donation from a donor"""
        new_list = []
        if min_donation is not None and max_donation is not None:
            #filter minimum & maximum
            new_list = list(
                filter(lambda donation: donation >= min_donation,
                       self._list_donations))
            new_list = list(
                filter(lambda donation_amount: donation_amount <= max_donation,
                       new_list))
            new_list = list(map(lambda x: x * factor, new_list))
        elif min_donation is None and max_donation is not None:
            #filter maximum
            new_list = list(
                filter(lambda donation: donation <= max_donation,
                       self._list_donations))
            new_list = list(map(lambda x: x * factor, new_list))
        elif min_donation is not None and max_donation is None:
            # filter minimum
            new_list = list(
                filter(lambda donation: donation >= min_donation,
                       self._list_donations))
            new_list = list(map(lambda x: x * factor, new_list))
        else:
            # no minimum and maximum
            new_list = list(map(lambda x: x * factor, self._list_donations))

        return sum(new_list)

    def __lt__(self, other):
        return self._amount < other._amount

    def __gt__(self, other):
        return self._amount > other._amount

    def __eq__(self, other):
        return self._amount == other._amount
Example #11
0
class Donor(js.JsonSaveable):
    """
    Class to store a donor record

    Args:
        full_name(str)      format: <first name> [<middle_name>] <last_name>
                                    [,<suffix>]
            or
        first_name(str)
        middle_name(str)
        last_name(str)
        suffix(str)

        donation(float)     amount of today's donation
            or
        donations(list)     list of dicts containing donations

        Not typically specified
        -----------------------
        did(str)            string representation of a record UUID
        created(str)        string representation of an utcnow isoformat
                            timestamp with "Z" appended

    Usage:

        The class allows flexibility in how a record is set.  It is allowable
        to create an empty instance of the class and update it piecemeal.

        It is also allowable to pass in all information needed to create a
        record using different strategies such as a full_name vs the
        constituent parts.

        In general, record manipulation is simply the name and donation.
        However, it is also allowable to set information that is normally
        auto-created such as the did, time created and entire donation list.
        This allows a record to be created from its repr which may have
        practical uses in record backup/recreation as needed.

    Example Use Cases:

        # empty donor record
        In [599]: d=Donor()
        In [600]: repr(d)
        Out[600]: "Donor(did='97d744de-de23-11e7-bee5-0800274b0a84',
            created='2017-12-11T03:30:11.077937Z' )"
        In [601]:

        # automatic parsing of full_name
        In [601]: d=Donor(full_name="sally q smith, iv")
        In [602]: repr(d)
        Out[602]: "Donor(did='067f51c4-de24-11e7-bee5-0800274b0a84',
                         first_name='Sally', middle_name='Q',
                         last_name='Smith', suffix='IV',
                         created='2017-12-11T03:33:16.728654Z' )"
        In [603]:

        # name creation based name sub-components
        In [594]: d=Donor(first_name="joe", last_name="smith", donation=100)
        In [595]: repr(d)
        Out[595]: "Donor(did='7724e750-de23-11e7-bee5-0800274b0a84',
                         first_name='Joe',last_name='Smith',
                         donations=[{'amount': 100.0, 'date': '2017-12-11Z'}],
                         created='2017-12-11T03:29:16.221954Z' )"
        In [596]:

        In [636]: d=Donor(full_name="john adams")
        In [637]: d.add_donation=1000
        In [638]: repr(d)
        Out[638]: "Donor(did='7e93d724-de25-11e7-bee5-0800274b0a84',
                         first_name='John',last_name='Adams',
                         donations=[{'amount': 1000.0, 'date': '2017-12-11Z'},
                         {'amount': 1000.0, 'date': '2017-12-11Z'}],
                         created='2017-12-11T03:43:47.686457Z' )"
        In [639]:

    """

    created = js.String()
    audit = js.List()
    _did = js.String()
    _first_name = js.String()
    _last_name = js.String()
    _middle_name = js.String()
    _suffix = js.String()
    _donations = js.List()

    def __init__(self,
                 did="",
                 created="",
                 full_name="",
                 first_name="",
                 last_name="",
                 middle_name="",
                 suffix="",
                 donations=None,
                 donation=None):

        self.__name__ = "Donor"

        # normally no did is passed in, so we create one
        if did == "":
            did = str(uuid.uuid1())
        # create a uuid for each record
        self.did = did

        # normally, no timestamp is passed in, so we create one
        if created == "":
            created = datetime.datetime.utcnow().isoformat() + "Z"
        # keep track of record creation
        self.created = created

        # test to see if any of the name components are set
        sub_name_set = any(sub_name != ""
                           for sub_name in (first_name, middle_name, last_name,
                                            suffix))

        # we don't expect full_name and a subset to be set at the same time
        if full_name != "" and sub_name_set:
            raise ValueError("You cannot set 'full_name' and a subset of the "
                             "name at the same time.")

        # set the name via full_name or via the constituent parts
        if full_name != "":
            self.full_name = full_name
        else:
            self.first_name = first_name.title()
            self.last_name = last_name.title()
            self.middle_name = middle_name.title()
            self.suffix = suffix.upper()

        if (donations is not None) and (donation is not None):
            raise ValueError("You cannot set 'donations' and 'donation' at "
                             "the same time.")

        if donation is not None:
            # if a donation is passed in, initialize the donations,
            # then add the donation
            self._donations = list()
            self.add_donation(donation)
        else:
            if donations is not None:
                # TODO donations need more validation
                self._donations = donations
            else:
                self._donations = list()

    @property
    def did(self):
        """ return the donor did """
        return self._did

    @did.setter
    @audit_log
    def did(self, value):
        """ set the did """
        self._did = value

    @property
    def donations(self):
        """ print all donations """
        return self._donations

    @donations.setter
    @audit_log
    def donations(self, value):
        """ allow bulk setting of donation entries """
        self._donations = value

    @audit_log
    def add_donation(self, value):
        """ add a single donation """
        today = datetime.datetime.utcnow().date().isoformat() + "Z"
        try:
            value = float(value)
        except ValueError:
            raise ValueError("Donations must be values.")
        self._donations.append({"amount": value, "date": today})

    @property
    def number_donations(self):
        """ return the number of donations """
        return len(self._donations)

    @property
    def total_donations(self):
        """ return a sum of the donations """
        total_donations = 0
        for donation in self.donations:
            total_donations += donation["amount"]
        return round(total_donations, 2)

    @property
    def average_donations(self):
        """ return the average donation amount """
        try:
            return round(self.total_donations / self.number_donations, 2)
        except ZeroDivisionError:
            return 0

    @property
    def first_name(self):
        """ return the first name """
        return self._first_name

    @first_name.setter
    @audit_log
    def first_name(self, value):
        """ set the first name """
        self._first_name = value.title()

    @property
    def middle_name(self):
        """ return the middle name """
        return self._middle_name

    @middle_name.setter
    @audit_log
    def middle_name(self, value):
        """ set the middle name """
        self._middle_name = value.title()

    @property
    def last_name(self):
        """ return the last name """
        return self._last_name

    @last_name.setter
    @audit_log
    def last_name(self, value):
        """ set the last name """
        self._last_name = value.title()

    @property
    def suffix(self):
        """ return the suffix """
        return self._suffix

    @suffix.setter
    @audit_log
    def suffix(self, value):
        """ set the suffix """
        # special case roman numbers to all caps
        if value.lower() in ["i", "ii", "iii", "iv", "v"]:
            self._suffix = value.upper()
        else:
            self._suffix = value.title()

    @property
    def informal_name(self):
        """ return full name minus the suffix """
        return " ".join(
            filter(None, [self.first_name, self.middle_name, self.last_name]))

    @property
    def full_name(self):
        """ return the formal, full name """
        # if suffix, add with a comma
        if self.suffix != "":
            suffix = ", " + self.suffix
        else:
            suffix = ""
        return " ".join(
            filter(None, [self.first_name, self.middle_name, self.last_name
                          ])) + suffix

    @full_name.setter
    def full_name(self, full_name):
        """ allow the donor information to be updated via full_name as well """

        # for simplicity's sake, we make the assumption a suffix will
        # follow a comma

        # capture suffix, if present
        suffix = ""
        if full_name.find(","):
            try:
                suffix = full_name.split(",")[1].strip()
            except Exception:
                pass

        # name with suffix removed
        informal_name = full_name.split(",")[0].strip()

        # we assume the last name is one word minus the suffix
        last_name = informal_name.split()[-1]

        # build a list with everything to the left of the last name
        everything_but_last = informal_name.split()[0:-1]

        # pull the first name off
        try:
            first_name = everything_but_last.pop(0)
        except IndexError:
            # if we get an error, they probably gave us a malformed name
            first_name = ""

        try:
            # pull the middle, if exists
            middle_name = everything_but_last.pop(0)
        except IndexError:
            middle_name = ""

        self.first_name = first_name
        self.middle_name = middle_name
        self.last_name = last_name
        self.suffix = suffix

    def _query_attributes(self, which_attributes=(), return_empty=True):
        """ return list of formatted attribute/value entries """
        attributes = []
        for attr in which_attributes:
            # for our purposes, never print internal attributes
            if not attr.startswith("_"):
                try:
                    str_val = repr(getattr(self, attr))
                    # don't return empty values if they don't want them
                    if eval(str_val) or return_empty:
                        attributes.append(attr + "=" + str_val)
                except (AttributeError, SyntaxError):
                    # we know certain attributes are not readable, so skip them
                    pass
        return attributes

    def __repr__(self):
        """
        Return only the (settable) attributes

        Return only settable attributes so
        eval(repr(Donor(<foo>))) == Donor(<foo>)
        """
        which_attributes = [
            "did", "first_name", "middle_name", "last_name", "suffix",
            "donations", "created"
        ]
        attributes = self._query_attributes(which_attributes,
                                            return_empty=False)
        return "Donor( " + ", ".join(attributes) + " )"

    def __str__(self):
        """ return all the non-internal attributes """
        which_attributes = []
        for attr in dir(self):
            if not attr.startswith("_"):
                which_attributes.append(attr)
        return "str(Donor(\n    " + ",\n    ".join(
            self._query_attributes(which_attributes)) + " ))"