Source code for api.opds

import urllib.request, urllib.parse, urllib.error
import copy
import logging
from flask import url_for
from lxml import etree
from collections import defaultdict
import uuid
from sqlalchemy.orm import lazyload

from core.cdn import cdnify
from core.classifier import Classifier
from core.entrypoint import (
    EverythingEntryPoint,
)
from core.external_search import WorkSearchResult
from core.opds import (
    Annotator,
    AcquisitionFeed,
    UnfulfillableWork,
)
from core.util.flask_util import OPDSFeedResponse
from core.util.opds_writer import (
    OPDSFeed,
)
from core.model import (
    CirculationEvent,
    ConfigurationSetting,
    Credential,
    CustomList,
    DataSource,
    DeliveryMechanism,
    Hold,
    Identifier,
    LicensePool,
    LicensePoolDeliveryMechanism,
    Loan,
    Patron,
    Session,
    Work,
    Edition,
)
from core.lane import (
    Lane,
    WorkList,
)
from core.util.datetime_helpers import from_timestamp
from api.lanes import (
    DynamicLane,
    CrawlableCustomListBasedLane,
    CrawlableCollectionBasedLane,
)
from core.app_server import cdn_url_for

from .util.short_client_token import ShortClientTokenUtility
from .annotations import AnnotationWriter
from .circulation import BaseCirculationAPI
from .config import (
    CannotLoadConfiguration,
    Configuration,
)
from .novelist import NoveListAPI
from core.analytics import Analytics

[docs]class CirculationManagerAnnotator(Annotator): def __init__(self, lane, active_loans_by_work={}, active_holds_by_work={}, active_fulfillments_by_work={}, hidden_content_types=[], test_mode=False): if lane: logger_name = "Circulation Manager Annotator for %s" % lane.display_name else: logger_name = "Circulation Manager Annotator" self.log = logging.getLogger(logger_name) self.lane = lane self.active_loans_by_work = active_loans_by_work self.active_holds_by_work = active_holds_by_work self.active_fulfillments_by_work = active_fulfillments_by_work self.hidden_content_types = hidden_content_types self.test_mode = test_mode
[docs] def is_work_entry_solo(self, work): """Return a boolean value indicating whether the work's OPDS catalog entry is served by itself, rather than as a part of the feed. :param work: Work object :type work: core.model.work.Work :return: Boolean value indicating whether the work's OPDS catalog entry is served by itself, rather than as a part of the feed :rtype: bool """ return any( work in x for x in (self.active_loans_by_work, self.active_holds_by_work, self.active_fulfillments_by_work) )
def _lane_identifier(self, lane): if isinstance(lane, Lane): return lane.id return None
[docs] def top_level_title(self): return ""
[docs] def default_lane_url(self): return self.feed_url(None)
[docs] def lane_url(self, lane): return self.feed_url(lane)
[docs] def url_for(self, *args, **kwargs): if self.test_mode: new_kwargs = {} for k, v in list(kwargs.items()): if not k.startswith('_'): new_kwargs[k] = v return self.test_url_for(False, *args, **new_kwargs) else: return url_for(*args, **kwargs)
[docs] def cdn_url_for(self, *args, **kwargs): if self.test_mode: return self.test_url_for(True, *args, **kwargs) else: return cdn_url_for(*args, **kwargs)
[docs] def test_url_for(self, cdn=False, *args, **kwargs): # Generate a plausible-looking URL that doesn't depend on Flask # being set up. if cdn: host = 'cdn' else: host = 'host' url = ("http://%s/" % host) + "/".join(args) connector = '?' for k, v in sorted(kwargs.items()): if v is None: v = '' v = urllib.parse.quote(str(v)) k = urllib.parse.quote(str(k)) url += connector + "%s=%s" % (k, v) connector = '&' return url
[docs] def facet_url(self, facets): return self.feed_url(self.lane, facets=facets, default_route=self.facet_view)
[docs] def feed_url(self, lane, facets=None, pagination=None, default_route='feed', extra_kwargs=None): if (isinstance(lane, WorkList) and hasattr(lane, 'url_arguments')): route, kwargs = lane.url_arguments else: route = default_route lane_identifier = self._lane_identifier(lane) kwargs = dict(lane_identifier=lane_identifier) if facets != None: kwargs.update(dict(list(facets.items()))) if pagination != None: kwargs.update(dict(list(pagination.items()))) if extra_kwargs: kwargs.update(extra_kwargs) return self.cdn_url_for(route, _external=True, **kwargs)
[docs] def navigation_url(self, lane): return self.cdn_url_for( "navigation_feed", lane_identifier=self._lane_identifier(lane), library_short_name=lane.library.short_name, _external=True)
[docs] def active_licensepool_for(self, work): loan = (self.active_loans_by_work.get(work) or self.active_holds_by_work.get(work)) if loan: # The active license pool is the one associated with # the loan/hold. return loan.license_pool else: # There is no active loan. Use the default logic for # determining the active license pool. return super( CirculationManagerAnnotator, self).active_licensepool_for(work)
[docs] def visible_delivery_mechanisms(self, licensepool): """Filter the given `licensepool`'s LicensePoolDeliveryMechanisms to those with content types that are not hidden. """ hidden = self.hidden_content_types for lpdm in licensepool.delivery_mechanisms: mechanism = lpdm.delivery_mechanism if not mechanism: # This shouldn't happen, but just in case. continue if mechanism.content_type in hidden: continue yield lpdm
[docs] def annotate_work_entry(self, work, active_license_pool, edition, identifier, feed, entry, updated=None): # If ElasticSearch included a more accurate last_update_time, # use it instead of Work.last_update_time updated = work.last_update_time if isinstance(work, WorkSearchResult): # Elasticsearch puts this field in a list, but we've set it up # so there will be at most one value. last_updates = getattr(work._hit, 'last_update', []) if last_updates: # last_update is seconds-since epoch; convert to UTC datetime. updated = from_timestamp(last_updates[0]) # There's a chance that work.last_updated has been # modified but the change hasn't made it to the search # engine yet. Even then, we stick with the search # engine value, because a sorted list is more # important to the import process than an up-to-date # 'last update' value. super(CirculationManagerAnnotator, self).annotate_work_entry( work, active_license_pool, edition, identifier, feed, entry, updated ) active_loan = self.active_loans_by_work.get(work) active_hold = self.active_holds_by_work.get(work) active_fulfillment = self.active_fulfillments_by_work.get(work) # Now we need to generate a <link> tag for every delivery mechanism # that has well-defined media types. link_tags = self.acquisition_links( active_license_pool, active_loan, active_hold, active_fulfillment, feed, identifier ) for tag in link_tags: entry.append(tag)
[docs] def rights_attributes(self, lpdm): """Create a dictionary of tag attributes that explain the rights status of a LicensePoolDeliveryMechanism. If nothing is known, the dictionary will be empty. """ if not lpdm or not lpdm.rights_status or not lpdm.rights_status.uri: return {} rights_attr = "{%s}rights" % OPDSFeed.DCTERMS_NS return {rights_attr : lpdm.rights_status.uri }
@classmethod def _single_entry_response( cls, _db, work, annotator, url, feed_class=AcquisitionFeed, **response_kwargs ): """Helper method to create an OPDSEntryResponse for a single OPDS entry. :param _db: A database connection. :param work: A Work :param annotator: An Annotator :param url: The URL of the feed to be served. Used only if there's a problem with the Work. :param feed_class: A replacement for AcquisitionFeed, for use in tests. :param response_kwargs: A set of extra keyword arguments to be passed into the OPDSEntryResponse constructor. :return: An OPDSEntryResponse if everything goes well; otherwise an OPDSFeedResponse containing an error message. """ if not work: return feed_class( _db, title="Unknown work", url=url, works=[], annotator=annotator ).as_error_response() # This method is generally used for reporting the results of # authenticated transactions such as borrowing and hold # placement. # # This means the document contains up-to-date information # specific to the authenticated client. The client should # cache this document for a while, but no one else should # cache it. response_kwargs.setdefault('max_age', 30*60) response_kwargs.setdefault('private', True) return feed_class.single_entry(_db, work, annotator, **response_kwargs)
[docs]class LibraryAnnotator(CirculationManagerAnnotator): TERMS_OF_SERVICE = Configuration.TERMS_OF_SERVICE PRIVACY_POLICY = Configuration.PRIVACY_POLICY COPYRIGHT = Configuration.COPYRIGHT ABOUT = Configuration.ABOUT LICENSE = Configuration.LICENSE REGISTER = Configuration.REGISTER CONFIGURATION_LINKS = [ TERMS_OF_SERVICE, PRIVACY_POLICY, COPYRIGHT, ABOUT, LICENSE, ] HELP_LINKS = [ Configuration.HELP_EMAIL, Configuration.HELP_WEB, Configuration.HELP_URI, ] def __init__(self, circulation, lane, library, patron=None, active_loans_by_work={}, active_holds_by_work={}, active_fulfillments_by_work={}, facet_view='feed', test_mode=False, top_level_title="All Books", library_identifies_patrons = True, facets=None ): """Constructor. :param library_identifies_patrons: A boolean indicating whether or not this library can distinguish between its patrons. A library might not authenticate patrons at all, or it might distinguish patrons from non-patrons in a way that does not allow it to keep track of individuals. If this is false, links that imply the library can distinguish between patrons will not be included. Depending on the configured collections, some extra links may be added, for direct acquisition of titles that would normally require a loan. """ super(LibraryAnnotator, self).__init__( lane, active_loans_by_work=active_loans_by_work, active_holds_by_work=active_holds_by_work, active_fulfillments_by_work=active_fulfillments_by_work, hidden_content_types=self._hidden_content_types(library), test_mode=test_mode ) self.circulation = circulation self.library = library self.patron = patron self.lanes_by_work = defaultdict(list) self.facet_view = facet_view self._adobe_id_tags = {} self._top_level_title = top_level_title self.identifies_patrons = library_identifies_patrons self.facets = facets or None @classmethod def _hidden_content_types(self, library): """Find all content types which this library should not be presenting to patrons. This is stored as a per-library setting. """ if not library: # This shouldn't happen, but we shouldn't crash if it does. return [] setting = library.setting(Configuration.HIDDEN_CONTENT_TYPES) if not setting or not setting.value: return [] try: hidden_types = setting.json_value except ValueError: hidden_types = setting.value hidden_types = hidden_types or [] if isinstance(hidden_types, str): hidden_types = [hidden_types] elif not isinstance(hidden_types, list): hidden_types = list(hidden_types) return hidden_types
[docs] def top_level_title(self): return self._top_level_title
[docs] def groups_url(self, lane, facets=None): lane_identifier = self._lane_identifier(lane) if facets: kwargs = dict(list(facets.items())) else: kwargs = {} return self.cdn_url_for( "acquisition_groups", lane_identifier=lane_identifier, library_short_name=self.library.short_name, _external=True, **kwargs )
[docs] def default_lane_url(self, facets=None): return self.groups_url(None, facets=facets)
[docs] def feed_url(self, lane, facets=None, pagination=None, default_route='feed'): extra_kwargs = dict() if self.library: extra_kwargs['library_short_name']=self.library.short_name return super(LibraryAnnotator, self).feed_url(lane, facets, pagination, default_route, extra_kwargs)
[docs] def search_url(self, lane, query, pagination, facets=None): lane_identifier = self._lane_identifier(lane) kwargs = dict(q=query) if facets: kwargs.update(dict(list(facets.items()))) if pagination: kwargs.update(dict(list(pagination.items()))) return self.url_for( "lane_search", lane_identifier=lane_identifier, library_short_name=self.library.short_name, _external=True, **kwargs)
[docs] def group_uri(self, work, license_pool, identifier): if not work in self.lanes_by_work: return None, "" lanes = self.lanes_by_work[work] if not lanes: # I don't think this should ever happen? lane_name = None url = self.cdn_url_for('acquisition_groups', lane_identifier=None, library_short_name=self.library.short_name, _external=True) title = "All Books" return url, title lane = lanes[0] self.lanes_by_work[work] = lanes[1:] lane_name = '' show_feed = False if isinstance(lane, dict): show_feed = lane.get('link_to_list_feed', show_feed) title = lane.get('label', lane_name) lane = lane['lane'] if isinstance(lane, str): return lane, lane_name if hasattr(lane, 'display_name') and not title: title = lane.display_name if show_feed: return self.feed_url(lane, self.facets), title return self.lane_url(lane, self.facets), title
[docs] def lane_url(self, lane, facets=None): # If the lane has sublanes, the URL identifying the group will # take the user to another set of groups for the # sublanes. Otherwise it will take the user to a list of the # books in the lane by author. if lane and isinstance(lane, Lane) and lane.sublanes: url = self.groups_url(lane, facets=facets) elif lane and ( isinstance(lane, Lane) or isinstance(lane, DynamicLane) ): url = self.feed_url(lane, facets) else: # This lane isn't part of our lane hierarchy. It's probably # a WorkList created to represent the top-level. Use the top-level # url for it. url = self.default_lane_url(facets=facets) return url
[docs] def annotate_work_entry(self, work, active_license_pool, edition, identifier, feed, entry): # Add a link for reporting problems. feed.add_link_to_entry( entry, rel='issues', href=self.url_for( 'report', identifier_type=identifier.type, identifier=identifier.identifier, library_short_name=self.library.short_name, _external=True ) ) super(LibraryAnnotator, self).annotate_work_entry( work, active_license_pool, edition, identifier, feed, entry ) # Add a link to each author tag. self.add_author_links(work, feed, entry) # And a series, if there is one. if work.series: self.add_series_link(work, feed, entry) if NoveListAPI.is_configured(self.library): # If NoveList Select is configured, there might be # recommendations, too. feed.add_link_to_entry( entry, rel='recommendations', type=OPDSFeed.ACQUISITION_FEED_TYPE, title='Recommended Works', href=self.url_for( 'recommendations', identifier_type=identifier.type, identifier=identifier.identifier, library_short_name=self.library.short_name, _external=True ) ) # Add a link for related books if available. if self.related_books_available(work, self.library): feed.add_link_to_entry( entry, rel='related', type=OPDSFeed.ACQUISITION_FEED_TYPE, title='Recommended Works', href=self.url_for( 'related_books', identifier_type=identifier.type, identifier=identifier.identifier, library_short_name=self.library.short_name, _external=True ) ) # Add a link to get a patron's annotations for this book. if self.identifies_patrons: feed.add_link_to_entry( entry, rel="http://www.w3.org/ns/oa#annotationService", type=AnnotationWriter.CONTENT_TYPE, href=self.url_for( 'annotations_for_work', identifier_type=identifier.type, identifier=identifier.identifier, library_short_name=self.library.short_name, _external=True ) ) if Analytics.is_configured(self.library): feed.add_link_to_entry( entry, rel="http://librarysimplified.org/terms/rel/analytics/open-book", href=self.url_for( 'track_analytics_event', identifier_type=identifier.type, identifier=identifier.identifier, event_type=CirculationEvent.OPEN_BOOK, library_short_name=self.library.short_name, _external=True ) )
[docs] @classmethod def related_books_available(cls, work, library): """:return: bool asserting whether related books might exist for a particular Work """ contributions = work.sort_author and work.sort_author != Edition.UNKNOWN_AUTHOR return (contributions or work.series or NoveListAPI.is_configured(library))
[docs] def language_and_audience_key_from_work(self, work): language_key = work.language audiences = None if work.audience == Classifier.AUDIENCE_CHILDREN: audiences = [Classifier.AUDIENCE_CHILDREN] elif work.audience == Classifier.AUDIENCE_YOUNG_ADULT: audiences = Classifier.AUDIENCES_JUVENILE elif work.audience == Classifier.AUDIENCE_ALL_AGES: audiences = [Classifier.AUDIENCE_CHILDREN, Classifier.AUDIENCE_ALL_AGES] elif work.audience in Classifier.AUDIENCES_ADULT: audiences = list(Classifier.AUDIENCES_NO_RESEARCH) elif work.audience == Classifier.AUDIENCE_RESEARCH: audiences = list(Classifier.AUDIENCES) else: audiences = [] audience_key=None if audiences: audience_strings = [urllib.parse.quote_plus(a) for a in sorted(audiences)] audience_key = ','.join(audience_strings) return language_key, audience_key
[docs] def annotate_feed(self, feed, lane): if self.patron: # A patron is authenticated. self.add_patron(feed) else: # No patron is authenticated. Show them how to # authenticate (or that authentication is not supported). self.add_authentication_document_link(feed) # Add a 'search' link if the lane is searchable. if lane and lane.search_target: search_facet_kwargs = {} if self.facets != None: if self.facets.entrypoint_is_default: # The currently selected entry point is a default. # Rather than using it, we want the 'default' behavior # for search, which is to search everything. search_facets = self.facets.navigate( entrypoint=EverythingEntryPoint ) else: search_facets = self.facets search_facet_kwargs.update(dict(list(search_facets.items()))) lane_identifier = self._lane_identifier(lane) search_url = self.url_for( 'lane_search', lane_identifier=lane_identifier, library_short_name=self.library.short_name, _external=True, **search_facet_kwargs ) search_link = dict( rel="search", type="application/opensearchdescription+xml", href=search_url ) feed.add_link_to_feed(feed.feed, **search_link) if self.identifies_patrons: # Since this library authenticates patrons it can offer # a bookshelf and an annotation service. shelf_link = dict( rel="http://opds-spec.org/shelf", type=OPDSFeed.ACQUISITION_FEED_TYPE, href=self.url_for('active_loans', library_short_name=self.library.short_name, _external=True)) feed.add_link_to_feed(feed.feed, **shelf_link) annotations_link = dict( rel="http://www.w3.org/ns/oa#annotationService", type=AnnotationWriter.CONTENT_TYPE, href=self.url_for('annotations', library_short_name=self.library.short_name, _external=True)) feed.add_link_to_feed(feed.feed, **annotations_link) if lane and lane.uses_customlists: name = None if hasattr(lane, "customlists") and len(lane.customlists) == 1: name = lane.customlists[0].name else: _db = Session.object_session(self.library) customlist = lane.get_customlists(_db) if customlist: name = customlist[0].name if name: crawlable_url = self.url_for( "crawlable_list_feed", list_name=name, library_short_name=self.library.short_name, _external=True ) crawlable_link = dict( rel="http://opds-spec.org/crawlable", type=OPDSFeed.ACQUISITION_FEED_TYPE, href=crawlable_url, ) feed.add_link_to_feed(feed.feed, **crawlable_link) self.add_configuration_links(feed)
[docs] def drm_device_registration_tags(self, license_pool, active_loan, delivery_mechanism): """Construct OPDS Extensions for DRM tags that explain how to register a device with the DRM server that manages this loan. :param delivery_mechanism: A DeliveryMechanism """ if not active_loan or not delivery_mechanism or not self.identifies_patrons: return [] if delivery_mechanism.drm_scheme == DeliveryMechanism.ADOBE_DRM: # Get an identifier for the patron that will be registered # with the DRM server. _db = Session.object_session(active_loan) patron = active_loan.patron # Generate a <drm:licensor> tag that can feed into the # Vendor ID service. return self.adobe_id_tags(patron) return []
[docs] def adobe_id_tags(self, patron_identifier): """Construct tags using the DRM Extensions for OPDS standard that explain how to get an Adobe ID for this patron, and how to manage their list of device IDs. :param delivery_mechanism: A DeliveryMechanism :return: If Adobe Vendor ID delegation is configured, a list containing a <drm:licensor> tag. If not, an empty list. """ # CirculationManagerAnnotators are created per request. # Within the context of a single request, we can cache the # tags that explain how the patron can get an Adobe ID, and # reuse them across <entry> tags. This saves a little time, # makes tests more reliable, and stops us from providing a # different Short Client Token for every <entry> tag. if isinstance(patron_identifier, Patron): cache_key = patron_identifier.id else: cache_key = patron_identifier cached = self._adobe_id_tags.get(cache_key) if cached is None: cached = [] authdata = None try: authdata = ShortClientTokenUtility.from_config(self.library) except CannotLoadConfiguration as e: logging.error("Cannot load Short Client Token configuration; outgoing OPDS entries will not have DRM autodiscovery support", exc_info=e) return [] if authdata: vendor_id, token = authdata.short_client_token_for_patron(patron_identifier) drm_licensor = OPDSFeed.makeelement("{%s}licensor" % OPDSFeed.DRM_NS) vendor_attr = "{%s}vendor" % OPDSFeed.DRM_NS drm_licensor.attrib[vendor_attr] = vendor_id patron_key = OPDSFeed.makeelement("{%s}clientToken" % OPDSFeed.DRM_NS) patron_key.text = token drm_licensor.append(patron_key) cached = [drm_licensor] self._adobe_id_tags[cache_key] = cached else: cached = copy.deepcopy(cached) return cached
[docs] def add_patron(self, feed_obj): if not self.identifies_patrons: return patron_details = {} if self.patron.username: patron_details["{%s}username" % OPDSFeed.SIMPLIFIED_NS] = self.patron.username if self.patron.authorization_identifier: patron_details["{%s}authorizationIdentifier" % OPDSFeed.SIMPLIFIED_NS] = self.patron.authorization_identifier patron_tag = OPDSFeed.makeelement("{%s}patron" % OPDSFeed.SIMPLIFIED_NS, patron_details) feed_obj.feed.append(patron_tag)
[docs]class SharedCollectionAnnotator(CirculationManagerAnnotator): def __init__(self, collection, lane, active_loans_by_work={}, active_holds_by_work={}, active_fulfillments_by_work={}, test_mode=False, ): super(SharedCollectionAnnotator, self).__init__(lane, active_loans_by_work=active_loans_by_work, active_holds_by_work=active_holds_by_work, active_fulfillments_by_work=active_fulfillments_by_work, test_mode=test_mode) self.collection = collection
[docs] def top_level_title(self): return self.collection.name
[docs] def default_lane_url(self): return self.feed_url(None, default_route='crawlable_collection_feed')
[docs] def lane_url(self, lane): return self.feed_url(lane, default_route='crawlable_collection_feed')
[docs] def feed_url(self, lane, facets=None, pagination=None, default_route='feed'): extra_kwargs = dict(collection_name=self.collection.name) return super(SharedCollectionAnnotator, self).feed_url(lane, facets, pagination, default_route, extra_kwargs)
[docs]class LibraryLoanAndHoldAnnotator(LibraryAnnotator):
[docs] @classmethod def active_loans_for( cls, circulation, patron, test_mode=False, **response_kwargs ): db = Session.object_session(patron) active_loans_by_work = {} for loan in patron.loans: work = loan.work if work: active_loans_by_work[work] = loan active_holds_by_work = {} for hold in patron.holds: work = hold.work if work: active_holds_by_work[work] = hold annotator = cls( circulation, None, patron.library, patron, active_loans_by_work, active_holds_by_work, test_mode=test_mode ) url = annotator.url_for('active_loans', library_short_name=patron.library.short_name, _external=True) works = patron.works_on_loan_or_on_hold() feed_obj = AcquisitionFeed(db, "Active loans and holds", url, works, annotator) annotator.annotate_feed(feed_obj, None) response = feed_obj.as_response(max_age=0, private=True) last_modified = patron.last_loan_activity_sync if last_modified: response.last_modified = last_modified return response
[docs] @classmethod def single_item_feed(cls, circulation, item, fulfillment=None, test_mode=False, feed_class=AcquisitionFeed, **response_kwargs): """Construct a response containing a single OPDS entry representing an active loan or hold. :param circulation: A CirculationAPI :param item: A Loan, Hold, or LicensePool if first two are missing. :param fulfillment: A FulfillmentInfo representing the format in which an active loan should be fulfilled. :param test_mode: Passed along to the constructor for this annotator class. :param feed_class: A drop-in replacement for AcquisitionFeed, for use in tests. :param response_kwargs: Extra keyword arguments to be passed into the OPDSEntryResponse constructor. :return: An OPDSEntryResponse """ if not item: raise ValueError("Argument 'item' must be non-empty") if isinstance(item, LicensePool): license_pool = item library = circulation.library elif isinstance(item, (Loan, Hold)): license_pool = item.license_pool library = item.library else: raise ValueError( "Argument 'item' must be an instance of {0}, {1}, or {2} classes".format( Loan, Hold, LicensePool ) ) _db = Session.object_session(item) work = license_pool.work or license_pool.presentation_edition.work active_loans_by_work = {} active_holds_by_work = {} active_fulfillments_by_work = {} item_dictionary = None if isinstance(item, Loan): item_dictionary = active_loans_by_work elif isinstance(item, Hold): item_dictionary = active_holds_by_work if item_dictionary is not None: item_dictionary[work] = item if fulfillment: active_fulfillments_by_work[work] = fulfillment annotator = cls( circulation, None, library, active_loans_by_work=active_loans_by_work, active_holds_by_work=active_holds_by_work, active_fulfillments_by_work=active_fulfillments_by_work, test_mode=test_mode ) identifier = license_pool.identifier url = annotator.url_for( 'loan_or_hold_detail', identifier_type=identifier.type, identifier=identifier.identifier, library_short_name=library.short_name, _external=True ) return annotator._single_entry_response( _db, work, annotator, url, feed_class, **response_kwargs )
[docs] def drm_device_registration_feed_tags(self, patron): """Return tags that provide information on DRM device deregistration independent of any particular loan. These tags will go under the <feed> tag. This allows us to deregister an Adobe ID, in preparation for logout, even if there is no active loan that requires one. """ tags = copy.deepcopy(self.adobe_id_tags(patron)) attr = '{%s}scheme' % OPDSFeed.DRM_NS for tag in tags: tag.attrib[attr] = "http://librarysimplified.org/terms/drm/scheme/ACS" return tags
@property def user_profile_management_protocol_link(self): """Create a <link> tag that points to the circulation manager's User Profile Management Protocol endpoint for the current patron. """ link = OPDSFeed.makeelement("link") link.attrib['rel'] = 'http://librarysimplified.org/terms/rel/user-profile' link.attrib['href'] = self.url_for( 'patron_profile', library_short_name=self.library.short_name, _external=True ) return link
[docs] def annotate_feed(self, feed, lane): """Annotate the feed with top-level DRM device registration tags and a link to the User Profile Management Protocol endpoint. """ super(LibraryLoanAndHoldAnnotator, self).annotate_feed( feed, lane ) if self.patron: tags = self.drm_device_registration_feed_tags(self.patron) tags.append(self.user_profile_management_protocol_link) for tag in tags: feed.feed.append(tag)
[docs]class SharedCollectionLoanAndHoldAnnotator(SharedCollectionAnnotator):
[docs] @classmethod def single_item_feed(cls, collection, item, fulfillment=None, test_mode=False, feed_class=AcquisitionFeed, **response_kwargs): """Create an OPDS entry representing a single loan or hold. TODO: This and LibraryLoanAndHoldAnnotator.single_item_feed can potentially be refactored. The main obstacle is different routes and arguments for 'loan info' and 'hold info'. :return: An OPDSEntryResponse """ _db = Session.object_session(item) license_pool = item.license_pool work = license_pool.work or license_pool.presentation_edition.work identifier = license_pool.identifier active_loans_by_work = {} active_holds_by_work = {} active_fulfillments_by_work = {} if fulfillment: active_fulfillments_by_work[work] = fulfillment if isinstance(item, Loan): d = active_loans_by_work route = 'shared_collection_loan_info' route_kwargs = dict(loan_id=item.id) elif isinstance(item, Hold): d = active_holds_by_work route = 'shared_collection_hold_info' route_kwargs = dict(hold_id=item.id) d[work] = item annotator = cls( collection, None, active_loans_by_work=active_loans_by_work, active_holds_by_work=active_holds_by_work, active_fulfillments_by_work=active_fulfillments_by_work, test_mode=test_mode ) url = annotator.url_for( route, collection_name=collection.name, _external=True, **route_kwargs ) return annotator._single_entry_response( _db, work, annotator, url, feed_class, **response_kwargs )