def refresh_creds(self, credential): """Fetch a new Bearer Token and update the given Credential object.""" response = self.token_post(self.TOKEN_ENDPOINT, dict(grant_type="client_credentials"), allowed_response_codes=[200, 400]) # If you put in the wrong URL, this is where you'll run into # problems, so it's useful to give a helpful error message if # Odilo doesn't provide anything more specific. generic_error = "%s may not be the right base URL. Response document was: %r" % ( self.library_api_base_url, response.content) generic_exception = BadResponseException(self.TOKEN_ENDPOINT, generic_error) try: data = response.json() except ValueError: raise generic_exception if response.status_code == 200: self._update_credential(credential, data) self.token = credential.credential return elif response.status_code == 400: if data and 'errors' in data and len(data['errors']) > 0: error = data['errors'][0] if 'description' in error: message = error['description'] else: message = generic_error raise BadResponseException(self.TOKEN_ENDPOINT, message) raise generic_exception
def test_as_problem_detail_document(self): exception = BadResponseException( "http://url/", "What even is this", debug_message="some debug info" ) document = exception.as_problem_detail_document(debug=True) assert 502 == document.status_code assert "Bad response" == document.title assert ( "The server made a request to http://url/, and got an unexpected or invalid response." == document.detail ) assert ( "Bad response from http://url/: What even is this\n\nsome debug info" == document.debug_message )
def circulation_request(self, identifiers): url = "/circulation/items/" + ",".join(identifiers) response = self.request(url) if response.status_code != 200: raise BadResponseException.bad_status_code(self.full_url(url), response) return response
def test_helper_constructor(self): response = MockRequestsResponse(102, content="nonsense") exc = BadResponseException.from_response( "http://url/", "Terrible response, just terrible", response ) # Turn the exception into a problem detail document, and it's full # of useful information. doc, status_code, headers = exc.as_problem_detail_document(debug=True).response doc = json.loads(doc) assert "Bad response" == doc["title"] assert ( "The server made a request to http://url/, and got an unexpected or invalid response." == doc["detail"] ) assert ( "Bad response from http://url/: Terrible response, just terrible\n\nStatus code: 102\nContent: nonsense" == doc["debug_message"] ) # Unless debug is turned off, in which case none of that # information is present. doc, status_code, headers = exc.as_problem_detail_document(debug=False).response assert "debug_message" not in json.loads(doc)
def update_loan(self, loan, status_doc=None): """Check a loan's status, and if it is no longer active, delete the loan and update its pool's availability. """ _db = Session.object_session(loan) if not status_doc: status_doc = self.get_license_status_document(loan) status = status_doc.get("status") # We already check that the status is valid in get_license_status_document, # but if the document came from a notification it hasn't been checked yet. if status not in self.STATUS_VALUES: raise BadResponseException( "The License Status Document had an unknown status value.") if status in [ self.REVOKED_STATUS, self.RETURNED_STATUS, self.CANCELLED_STATUS, self.EXPIRED_STATUS ]: # This loan is no longer active. Update the pool's availability # and delete the loan. # If there are holds, the license is reserved for the next patron. _db.delete(loan) self.update_hold_queue(loan.license_pool)
def check_content_type(self, response): content_type = response.headers.get('content-type') if content_type != OPDSFeed.ACQUISITION_FEED_TYPE: raise BadResponseException.from_response( response.url, "Wrong media type: %s" % content_type, response )
def test_bad_status_code_helper(object): response = MockRequestsResponse(500, content="Internal Server Error!") exc = BadResponseException.bad_status_code("http://url/", response) doc, status_code, headers = exc.as_problem_detail_document(debug=True).response doc = json.loads(doc) assert doc["debug_message"].startswith( "Bad response from http://url/: Got status code 500 from external server, cannot continue." )
def get_circulation_for(self, identifiers): """Return circulation objects for the selected identifiers.""" url = "/circulation/items/" + ",".join(identifiers) response = self.request(url) if response.status_code != 200: raise BadResponseException.bad_status_code( self.full_url(url), response ) for circ in CirculationParser().process_all(response.content): if circ: yield circ
def import_feed_response(self, response, id_mapping): """Confirms OPDS feed response and imports feed. """ content_type = response.headers['content-type'] if content_type != OPDSFeed.ACQUISITION_FEED_TYPE: raise BadResponseException.from_response( response.url, "Wrong media type: %s" % content_type, response ) importer = OPDSImporter(self._db, identifier_mapping=id_mapping) return importer.import_from_feed(response.text)
def get_license_status_document(self, loan): """Get the License Status Document for a loan. For a new loan, create a local loan with no external identifier and pass it in to this method. This will create the remote loan if one doesn't exist yet. The loan's internal database id will be used to receive notifications from the distributor when the loan's status changes. """ _db = Session.object_session(loan) if loan.external_identifier: url = loan.external_identifier else: id = loan.license_pool.identifier.identifier checkout_id = str(uuid.uuid1()) default_loan_period = self.collection(_db).default_loan_period( loan.patron.library) expires = datetime.datetime.utcnow() + datetime.timedelta( days=default_loan_period) # The patron UUID is generated randomly on each loan, so the distributor # doesn't know when multiple loans come from the same patron. patron_id = str(uuid.uuid1()) notification_url = self._url_for( "odl_notify", library_short_name=loan.patron.library.short_name, loan_id=loan.id, _external=True, ) params = dict( url=self.consolidated_loan_url, id=id, checkout_id=checkout_id, patron_id=patron_id, expires=(expires.isoformat() + 'Z'), notification_url=notification_url, ) url = "%(url)s?id=%(id)s&checkout_id=%(checkout_id)s&patron_id=%(patron_id)s&expires=%(expires)s¬ification_url=%(notification_url)s" % params response = self._get(url) try: status_doc = json.loads(response.content) except ValueError, e: raise BadResponseException( url, "License Status Document was not valid JSON.")
def get(self, url, extra_headers={}, exception_on_401=False): """Make an HTTP GET request using the active Bearer Token.""" if extra_headers is None: extra_headers = {} headers = dict(Authorization="Bearer %s" % self.token) headers.update(extra_headers) status_code, headers, content = self._do_get( self.library_api_base_url + url, headers) if status_code == 401: if exception_on_401: # This is our second try. Give up. raise BadResponseException.from_response( url, "Something's wrong with the Odilo OAuth Bearer Token!", (status_code, headers, content)) else: # Refresh the token and try again. self.check_creds(True) return self.get(url, extra_headers, True) else: return status_code, headers, content
class ODLWithConsolidatedCopiesAPI(BaseCirculationAPI): """ODL (Open Distribution to Libraries) is a specification that allows libraries to manage their own loans and holds. It offers a deeper level of control to the library, but implementing full ODL support will require changing the circulation manager to keep track of individual copies rather than license pools, and manage its own holds queues. 'ODL With Consolidated Copies' builds on ODL to provide an API that is more consistent with what other distributors provide. In addition to an ODL feed, the 'ODL With Consolidated Copies' distributor provides an endpoint to get a consolidated copies feed. Each consolidated copy has the total number of licenses owned and available across all the library's copies. In addition, the distributor provides an endpoint to create a loan for a consolidated copy, rather than an individual copy. That endpoint returns an License Status Document (https://readium.github.io/readium-lsd-specification/) and can also be used to check the status of an existing loan. When the circulation manager has full ODL support, the consolidated copies code can be removed. """ NAME = "ODL with Consolidated Copies" DESCRIPTION = _( "Import books from a distributor that uses ODL (Open Distribution to Libraries) and has a consolidated copies API." ) CONSOLIDATED_COPIES_URL_KEY = "consolidated_copies_url" CONSOLIDATED_LOAN_URL_KEY = "consolidated_loan_url" SETTINGS = [ { "key": Collection.EXTERNAL_ACCOUNT_ID_KEY, "label": _("Metadata URL (ODL feed)"), }, { "key": CONSOLIDATED_COPIES_URL_KEY, "label": _("Consolidated Copies URL"), }, { "key": CONSOLIDATED_LOAN_URL_KEY, "label": _("Consolidated Loan URL"), }, { "key": ExternalIntegration.USERNAME, "label": _("Library's API username"), }, { "key": ExternalIntegration.PASSWORD, "label": _("Library's API password"), }, { "key": Collection.DATA_SOURCE_NAME_SETTING, "label": _("Data source name"), }, { "key": Collection.DEFAULT_RESERVATION_PERIOD_KEY, "label": _("Default Reservation Period (in Days)"), "description": _("The number of days a patron has to check out a book after a hold becomes available." ), "type": "number", "default": Collection.STANDARD_DEFAULT_RESERVATION_PERIOD, }, ] LIBRARY_SETTINGS = BaseCirculationAPI.LIBRARY_SETTINGS + [ BaseCirculationAPI.EBOOK_LOAN_DURATION_SETTING ] SET_DELIVERY_MECHANISM_AT = BaseCirculationAPI.FULFILL_STEP TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" # Possible status values in the License Status Document: # The license is available but the user hasn't fulfilled it yet. READY_STATUS = "ready" # The license is available and has been fulfilled on at least one device. ACTIVE_STATUS = "active" # The license has been revoked by the distributor. REVOKED_STATUS = "revoked" # The license has been returned early by the user. RETURNED_STATUS = "returned" # The license was returned early and was never fulfilled. CANCELLED_STATUS = "cancelled" # The license has expired. EXPIRED_STATUS = "expired" STATUS_VALUES = [ READY_STATUS, ACTIVE_STATUS, REVOKED_STATUS, RETURNED_STATUS, CANCELLED_STATUS, EXPIRED_STATUS, ] def __init__(self, _db, collection): if collection.protocol != self.NAME: raise ValueError( "Collection protocol is %s, but passed into ODLWithConsolidatedCopiesAPI!" % collection.protocol) self.collection_id = collection.id self.data_source_name = collection.external_integration.setting( Collection.DATA_SOURCE_NAME_SETTING).value # Create the data source if it doesn't exist yet. DataSource.lookup(_db, self.data_source_name, autocreate=True) self.username = collection.external_integration.username self.password = collection.external_integration.password self.consolidated_loan_url = collection.external_integration.setting( self.CONSOLIDATED_LOAN_URL_KEY).value def internal_format(self, delivery_mechanism): """Each consolidated copy is only available in one format, so we don't need a mapping to internal formats. """ return delivery_mechanism def collection(self, _db): return get_one(_db, Collection, id=self.collection_id) def _get(self, url, headers=None): """Make a normal HTTP request, but include an authentication header with the credentials for the collection. """ username = self.username password = self.password headers = dict(headers or {}) auth_header = "Basic %s" % base64.b64encode("%s:%s" % (username, password)) headers['Authorization'] = auth_header return HTTP.get_with_timeout(url, headers=headers) def _url_for(self, *args, **kwargs): """Wrapper around flask's url_for to be overridden for tests. """ return url_for(*args, **kwargs) def get_license_status_document(self, loan): """Get the License Status Document for a loan. For a new loan, create a local loan with no external identifier and pass it in to this method. This will create the remote loan if one doesn't exist yet. The loan's internal database id will be used to receive notifications from the distributor when the loan's status changes. """ _db = Session.object_session(loan) if loan.external_identifier: url = loan.external_identifier else: id = loan.license_pool.identifier.identifier checkout_id = str(uuid.uuid1()) default_loan_period = self.collection(_db).default_loan_period( loan.patron.library) expires = datetime.datetime.utcnow() + datetime.timedelta( days=default_loan_period) # The patron UUID is generated randomly on each loan, so the distributor # doesn't know when multiple loans come from the same patron. patron_id = str(uuid.uuid1()) notification_url = self._url_for( "odl_notify", library_short_name=loan.patron.library.short_name, loan_id=loan.id, _external=True, ) params = dict( url=self.consolidated_loan_url, id=id, checkout_id=checkout_id, patron_id=patron_id, expires=(expires.isoformat() + 'Z'), notification_url=notification_url, ) url = "%(url)s?id=%(id)s&checkout_id=%(checkout_id)s&patron_id=%(patron_id)s&expires=%(expires)s¬ification_url=%(notification_url)s" % params response = self._get(url) try: status_doc = json.loads(response.content) except ValueError, e: raise BadResponseException( url, "License Status Document was not valid JSON.") if status_doc.get("status") not in self.STATUS_VALUES: raise BadResponseException( url, "License Status Document had an unknown status value.") return status_doc