def max_outstanding_fines(cls, library): max_fines = ConfigurationSetting.for_library( cls.MAX_OUTSTANDING_FINES, library ) if max_fines.value is None: return None return MoneyUtility.parse(max_fines.value)
def test_has_excess_fines(self): # Test the has_excess_fines method. patron = self._patron() # If you accrue excessive fines you lose borrowing privileges. setting = ConfigurationSetting.for_library( Configuration.MAX_OUTSTANDING_FINES, self._default_library ) # Verify that all these tests work no matter what data type has been stored in # patron.fines. for patron_fines in ("1", "0.75", 1, 1.0, Decimal(1), MoneyUtility.parse("1")): patron.fines = patron_fines # Test cases where the patron's fines exceed a well-defined limit, # or when any amount of fines is too much. for max_fines in ["$0.50", "0.5", 0.5] + [ # well-defined limit "$0", "$0.00", "0", 0, ]: # any fines is too much setting.value = max_fines assert True == PatronUtility.has_excess_fines(patron) # Test cases where the patron's fines are below a # well-defined limit, or where fines are ignored # altogether. for max_fines in ["$100", 100] + [ # well-defined-limit None, "", ]: # fines ignored setting.value = max_fines assert False == PatronUtility.has_excess_fines(patron) # Test various cases where fines in any amount deny borrowing # privileges, but the patron has no fines. for patron_fines in ("0", "$0", 0, None, MoneyUtility.parse("$0")): patron.fines = patron_fines for max_fines in ["$0", "$0.00", "0", 0]: setting.value = max_fines assert False == PatronUtility.has_excess_fines(patron)
def has_excess_fines(cls, patron): """Does this patron have fines in excess of the maximum fine amount set for their library? :param a Patron: :return: A boolean """ if not patron.fines: return False if isinstance(patron.fines, Money): patron_fines = patron.fines else: patron_fines = MoneyUtility.parse(patron.fines) actual_fines = patron_fines.amount max_fines = Configuration.max_outstanding_fines(patron.library) if max_fines is not None and actual_fines > max_fines.amount: return True return False
def info_to_patrondata(cls, info): """Convert the SIP-specific dictionary obtained from SIPClient.patron_information() to an abstract, authenticator-independent PatronData object. """ if info.get('valid_patron_password') == 'N': # The patron did not authenticate correctly. Don't # return any data. return None # TODO: I'm not 100% convinced that a missing CQ field # always means "we don't have passwords so you're # authenticated," rather than "you didn't provide a # password so we didn't check." patrondata = PatronData() if 'sipserver_internal_id' in info: patrondata.permanent_id = info['sipserver_internal_id'] if 'patron_identifier' in info: patrondata.authorization_identifier = info['patron_identifier'] if 'email_address' in info: patrondata.email_address = info['email_address'] if 'personal_name' in info: patrondata.personal_name = info['personal_name'] if 'fee_amount' in info: fines = info['fee_amount'] else: fines = '0' patrondata.fines = MoneyUtility.parse(fines) if 'sipserver_patron_class' in info: patrondata.external_type = info['sipserver_patron_class'] for expire_field in [ 'sipserver_patron_expiration', 'polaris_patron_expiration' ]: if expire_field in info: value = info.get(expire_field) value = cls.parse_date(value) if value: patrondata.authorization_expires = value break return patrondata
def info_to_patrondata(cls, info): """Convert the SIP-specific dictionary obtained from SIPClient.patron_information() to an abstract, authenticator-independent PatronData object. """ if info.get('valid_patron_password') == 'N': # The patron did not authenticate correctly. Don't # return any data. return None # TODO: I'm not 100% convinced that a missing CQ field # always means "we don't have passwords so you're # authenticated," rather than "you didn't provide a # password so we didn't check." patrondata = PatronData() if 'sipserver_internal_id' in info: patrondata.permanent_id = info['sipserver_internal_id'] if 'patron_identifier' in info: patrondata.authorization_identifier = info['patron_identifier'] if 'email_address' in info: patrondata.email_address = info['email_address'] if 'personal_name' in info: patrondata.personal_name = info['personal_name'] if 'fee_amount' in info: fines = info['fee_amount'] else: fines = '0' patrondata.fines = MoneyUtility.parse(fines) if 'sipserver_patron_class' in info: patrondata.external_type = info['sipserver_patron_class'] for expire_field in [ 'sipserver_patron_expiration', 'polaris_patron_expiration' ]: if expire_field in info: value = info.get(expire_field) value = cls.parse_date(value) if value: patrondata.authorization_expires = value break # A True value in most (but not all) subfields of the # patron_status field will prohibit the patron from borrowing # books. status = info['patron_status_parsed'] block_reason = PatronData.NO_VALUE for field in SIPClient.PATRON_STATUS_FIELDS_THAT_DENY_BORROWING_PRIVILEGES: if status.get(field) is True: block_reason = cls.SPECIFIC_BLOCK_REASONS.get( field, PatronData.UNKNOWN_BLOCK) if block_reason not in (PatronData.NO_VALUE, PatronData.UNKNOWN_BLOCK): # Even if there are multiple problems with this # patron's account, we can now present a specific # error message. There's no need to look through # more fields. break patrondata.block_reason = block_reason # If we can tell by looking at the SIP2 message that the # patron has excessive fines, we can use that as the reason # they're blocked. if 'fee_limit' in info: fee_limit = MoneyUtility.parse(info['fee_limit']).amount if fee_limit and patrondata.fines > fee_limit: patrondata.block_reason = PatronData.EXCESSIVE_FINES return patrondata
def patron_dump_to_patrondata(self, current_identifier, content): """Convert an HTML patron dump to a PatronData object. :param current_identifier: Either the authorization identifier the patron just logged in with, or the one currently associated with their Patron record. Keeping track of this ensures we don't change a patron's preferred authorization identifier out from under them. :param content: The HTML document containing the patron dump. """ # If we don't see these fields, erase any previous value # rather than leaving the old value in place. This shouldn't # happen (unless the expiration date changes to an invalid # date), but just to be safe. permanent_id = PatronData.NO_VALUE username = authorization_expires = personal_name = PatronData.NO_VALUE email_address = fines = external_type = PatronData.NO_VALUE block_reason = PatronData.NO_VALUE neighborhood = PatronData.NO_VALUE potential_identifiers = [] for k, v in self._extract_text_nodes(content): if k == self.BARCODE_FIELD: if any(x.search(v) for x in self.blacklist): # This barcode contains a blacklisted # string. Ignore it, even if this means the patron # ends up with no barcode whatsoever. continue # We'll figure out which barcode is the 'right' one # later. potential_identifiers.append(v) # The millenium API doesn't care about spaces, so we add # a version of the barcode without spaces to our identifers # list as well. if " " in v: potential_identifiers.append(v.replace(" ", "")) elif k == self.RECORD_NUMBER_FIELD: permanent_id = v elif k == self.USERNAME_FIELD: username = v elif k == self.PERSONAL_NAME_FIELD: personal_name = v elif k == self.EMAIL_ADDRESS_FIELD: email_address = v elif k == self.FINES_FIELD: try: fines = MoneyUtility.parse(v) except ValueError: self.log.warning( 'Malformed fine amount for patron: "%s". Treating as no fines.' ) fines = Money("0", "USD") elif k == self.BLOCK_FIELD: block_reason = self._patron_block_reason(self.block_types, v) elif k == self.EXPIRATION_FIELD: try: # Parse the expiration date according to server local # time, not UTC. expires_local = datetime.datetime.strptime( v, self.EXPIRATION_DATE_FORMAT).replace( tzinfo=dateutil.tz.tzlocal()) expires_local = expires_local.date() authorization_expires = expires_local except ValueError: self.log.warning( 'Malformed expiration date for patron: "%s". Treating as unexpirable.', v, ) elif k == self.PATRON_TYPE_FIELD: external_type = v elif (k == self.HOME_BRANCH_FIELD and self.neighborhood_mode == self.HOME_BRANCH_NEIGHBORHOOD_MODE): neighborhood = v.strip() elif (k == self.ADDRESS_FIELD and self.neighborhood_mode == self.POSTAL_CODE_NEIGHBORHOOD_MODE): neighborhood = self.extract_postal_code(v) elif k == self.ERROR_MESSAGE_FIELD: # An error has occured. Most likely the patron lookup # failed. return None # Set the library identifier field library_identifier = None for k, v in self._extract_text_nodes(content): if k == self.library_identifier_field: library_identifier = v.strip() # We may now have multiple authorization # identifiers. PatronData expects the best authorization # identifier to show up first in the list. # # The last identifier in the list is probably the most recently # added one. In the absence of any other information, it's the # one we should choose. potential_identifiers.reverse() authorization_identifiers = potential_identifiers if not authorization_identifiers: authorization_identifiers = PatronData.NO_VALUE elif current_identifier in authorization_identifiers: # Don't rock the boat. The patron is used to using this # identifier and there's no need to change it. Move the # currently used identifier to the front of the list. authorization_identifiers.remove(current_identifier) authorization_identifiers.insert(0, current_identifier) data = PatronData( permanent_id=permanent_id, authorization_identifier=authorization_identifiers, username=username, personal_name=personal_name, email_address=email_address, authorization_expires=authorization_expires, external_type=external_type, fines=fines, block_reason=block_reason, library_identifier=library_identifier, neighborhood=neighborhood, # We must cache neighborhood information in the patron's # database record because syncing with the ILS is so # expensive. cached_neighborhood=neighborhood, complete=True, ) return data
def max_outstanding_fines(cls): max_fines = Configuration.policy(Configuration.MAX_OUTSTANDING_FINES) return MoneyUtility.parse(max_fines)
def patron_dump_to_patrondata(self, current_identifier, content): """Convert an HTML patron dump to a PatronData object. :param current_identifier: Either the authorization identifier the patron just logged in with, or the one currently associated with their Patron record. Keeping track of this ensures we don't change a patron's preferred authorization identifier out from under them. :param content: The HTML document containing the patron dump. """ # If we don't see these fields, erase any previous value # rather than leaving the old value in place. This shouldn't # happen (unless the expiration date changes to an invalid # date), but just to be safe. permanent_id = PatronData.NO_VALUE username = authorization_expires = personal_name = PatronData.NO_VALUE email_address = fines = external_type = PatronData.NO_VALUE block_reason = PatronData.NO_VALUE potential_identifiers = [] for k, v in self._extract_text_nodes(content): if k == self.BARCODE_FIELD: if any(x.search(v) for x in self.blacklist): # This barcode contains a blacklisted # string. Ignore it, even if this means the patron # ends up with no barcode whatsoever. continue # We'll figure out which barcode is the 'right' one # later. potential_identifiers.append(v) elif k == self.RECORD_NUMBER_FIELD: permanent_id = v elif k == self.USERNAME_FIELD: username = v elif k == self.PERSONAL_NAME_FIELD: personal_name = v elif k == self.EMAIL_ADDRESS_FIELD: email_address = v elif k == self.FINES_FIELD: try: fines = MoneyUtility.parse(v) except ValueError: self.log.warn( 'Malformed fine amount for patron: "%s". Treating as no fines.' ) fines = Money("0", "USD") elif k == self.BLOCK_FIELD: block_reason = self._patron_block_reason(self.block_types, v) elif k == self.EXPIRATION_FIELD: try: expires = datetime.datetime.strptime( v, self.EXPIRATION_DATE_FORMAT).date() authorization_expires = expires except ValueError: self.log.warn( 'Malformed expiration date for patron: "%s". Treating as unexpirable.', v ) elif k == self.PATRON_TYPE_FIELD: external_type = v elif k == self.ERROR_MESSAGE_FIELD: # An error has occured. Most likely the patron lookup # failed. return None # We may now have multiple authorization # identifiers. PatronData expects the best authorization # identifier to show up first in the list. # # The last identifier in the list is probably the most recently # added one. In the absence of any other information, it's the # one we should choose. potential_identifiers.reverse() authorization_identifiers = potential_identifiers if not authorization_identifiers: authorization_identifiers = PatronData.NO_VALUE elif current_identifier in authorization_identifiers: # Don't rock the boat. The patron is used to using this # identifier and there's no need to change it. Move the # currently used identifier to the front of the list. authorization_identifiers.remove(current_identifier) authorization_identifiers.insert(0, current_identifier) data = PatronData( permanent_id=permanent_id, authorization_identifier=authorization_identifiers, username=username, personal_name=personal_name, email_address=email_address, authorization_expires=authorization_expires, external_type=external_type, fines=fines, block_reason=block_reason, complete=True ) return data
def max_outstanding_fines(cls, library): max_fines = ConfigurationSetting.for_library(cls.MAX_OUTSTANDING_FINES, library) if max_fines.value is None: return None return MoneyUtility.parse(max_fines.value)
def info_to_patrondata(self, info, validate_password=True): """Convert the SIP-specific dictionary obtained from SIPClient.patron_information() to an abstract, authenticator-independent PatronData object. """ if info.get("valid_patron", "N") == "N": # The patron could not be identified as a patron of this # library. Don't return any data. return None if info.get("valid_patron_password") == "N" and validate_password: # The patron did not authenticate correctly. Don't # return any data. return None # TODO: I'm not 100% convinced that a missing CQ field # always means "we don't have passwords so you're # authenticated," rather than "you didn't provide a # password so we didn't check." patrondata = PatronData() if "sipserver_internal_id" in info: patrondata.permanent_id = info["sipserver_internal_id"] if "patron_identifier" in info: patrondata.authorization_identifier = info["patron_identifier"] if "email_address" in info: patrondata.email_address = info["email_address"] if "personal_name" in info: patrondata.personal_name = info["personal_name"] if "fee_amount" in info: fines = info["fee_amount"] else: fines = "0" patrondata.fines = MoneyUtility.parse(fines) if "sipserver_patron_class" in info: patrondata.external_type = info["sipserver_patron_class"] for expire_field in [ "sipserver_patron_expiration", "polaris_patron_expiration", ]: if expire_field in info: value = info.get(expire_field) value = self.parse_date(value) if value: patrondata.authorization_expires = value break # A True value in most (but not all) subfields of the # patron_status field will prohibit the patron from borrowing # books. status = info["patron_status_parsed"] block_reason = PatronData.NO_VALUE for field in self.fields_that_deny_borrowing: if status.get(field) is True: block_reason = self.SPECIFIC_BLOCK_REASONS.get( field, PatronData.UNKNOWN_BLOCK ) if block_reason not in (PatronData.NO_VALUE, PatronData.UNKNOWN_BLOCK): # Even if there are multiple problems with this # patron's account, we can now present a specific # error message. There's no need to look through # more fields. break patrondata.block_reason = block_reason # If we can tell by looking at the SIP2 message that the # patron has excessive fines, we can use that as the reason # they're blocked. if "fee_limit" in info: fee_limit = MoneyUtility.parse(info["fee_limit"]).amount if fee_limit and patrondata.fines > fee_limit: patrondata.block_reason = PatronData.EXCESSIVE_FINES return patrondata