Example #1
0
    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
Example #2
0
 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
     )
Example #3
0
 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
Example #4
0
    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)
Example #5
0
    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)
Example #6
0
 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
         )
Example #7
0
    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."
        )
Example #8
0
 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
Example #9
0
    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)
Example #10
0
    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&notification_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.")
Example #11
0
 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
Example #12
0
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&notification_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