diff --git a/api/admin/controller.py b/api/admin/controller.py index 56435162c..d1526ef4a 100644 --- a/api/admin/controller.py +++ b/api/admin/controller.py @@ -92,6 +92,9 @@ from api.novelist import NoveListAPI from core.opds_import import MetadataWranglerOPDSLookup +from api.google_analytics_provider import GoogleAnalyticsProvider +from core.local_analytics_provider import LocalAnalyticsProvider + def setup_admin_controllers(manager): """Set up all the controllers that will be used by the admin parts of the web app.""" if not manager.testing: @@ -1248,12 +1251,12 @@ def admin_auth_services(self): auth_service = ExternalIntegration.admin_authentication(self._db) if auth_service: if id and id != auth_service.id: - return MISSING_ADMIN_AUTH_SERVICE + return MISSING_SERVICE if protocol != auth_service.protocol: return CANNOT_CHANGE_PROTOCOL else: if id: - return MISSING_ADMIN_AUTH_SERVICE + return MISSING_SERVICE if protocol: auth_service, is_new = get_one_or_create( @@ -1366,7 +1369,7 @@ def patron_auth_services(self): if id: auth_service = get_one(self._db, ExternalIntegration, id=id, goal=ExternalIntegration.PATRON_AUTH_GOAL) if not auth_service: - return MISSING_PATRON_AUTH_SERVICE + return MISSING_SERVICE if protocol != auth_service.protocol: return CANNOT_CHANGE_PROTOCOL else: @@ -1480,7 +1483,7 @@ def metadata_services(self): if id: service = get_one(self._db, ExternalIntegration, id=id, goal=ExternalIntegration.METADATA_GOAL) if not service: - return MISSING_METADATA_SERVICE + return MISSING_SERVICE if protocol != service.protocol: return CANNOT_CHANGE_PROTOCOL else: @@ -1511,3 +1514,85 @@ def metadata_services(self): else: return Response(unicode(_("Success")), 200) + def analytics_services(self): + provider_apis = [GoogleAnalyticsProvider, + LocalAnalyticsProvider, + ] + protocols = self._get_integration_protocols(provider_apis) + + if flask.request.method == 'GET': + services = [] + for service in self._db.query(ExternalIntegration).filter( + ExternalIntegration.goal==ExternalIntegration.ANALYTICS_GOAL): + + [protocol] = [p for p in protocols if p.get("name") == service.protocol] + libraries = [] + for library in service.libraries: + library_info = dict(short_name=library.short_name) + for setting in protocol.get("library_settings", []): + key = setting.get("key") + value = ConfigurationSetting.for_library_and_externalintegration( + self._db, key, library, service + ).value + if value: + library_info[key] = value + libraries.append(library_info) + + settings = { setting.key: setting.value for setting in service.settings if setting.library_id == None} + + services.append( + dict( + id=service.id, + name=service.name, + protocol=service.protocol, + settings=settings, + libraries=libraries, + ) + ) + + return dict( + analytics_services=services, + protocols=protocols, + ) + + id = flask.request.form.get("id") + + protocol = flask.request.form.get("protocol") + if protocol and protocol not in [p.get("name") for p in protocols]: + return UNKNOWN_PROTOCOL + + is_new = False + if id: + service = get_one(self._db, ExternalIntegration, id=id, goal=ExternalIntegration.ANALYTICS_GOAL) + if not service: + return MISSING_SERVICE + if protocol != service.protocol: + return CANNOT_CHANGE_PROTOCOL + else: + if protocol: + service, is_new = create( + self._db, ExternalIntegration, protocol=protocol, + goal=ExternalIntegration.ANALYTICS_GOAL + ) + else: + return NO_PROTOCOL_FOR_NEW_SERVICE + + name = flask.request.form.get("name") + if name: + if service.name != name: + service_with_name = get_one(self._db, ExternalIntegration, name=name) + if service_with_name: + self._db.rollback() + return INTEGRATION_NAME_ALREADY_IN_USE + service.name = name + + [protocol] = [p for p in protocols if p.get("name") == protocol] + result = self._set_integration_settings_and_libraries(service, protocol) + if isinstance(result, ProblemDetail): + return result + + if is_new: + return Response(unicode(_("Success")), 201) + else: + return Response(unicode(_("Success")), 200) + diff --git a/api/admin/package.json b/api/admin/package.json index 2a569dfa6..8e879c6f6 100644 --- a/api/admin/package.json +++ b/api/admin/package.json @@ -9,6 +9,6 @@ "author": "NYPL", "license": "Apache-2.0", "dependencies": { - "simplified-circulation-web": "0.0.26" + "simplified-circulation-web": "0.0.27" } } diff --git a/api/admin/problem_details.py b/api/admin/problem_details.py index 7566331cb..3b48b7b7e 100644 --- a/api/admin/problem_details.py +++ b/api/admin/problem_details.py @@ -198,18 +198,11 @@ detail=_("You tried to store a password for an individual admin, but the database does not have the pgcrypto extension installed."), ) -MISSING_ADMIN_AUTH_SERVICE = pd( - "http://librarysimplified.org/terms/problem/missing-admin-auth-service", +MISSING_SERVICE = pd( + "http://librarysimplified.org/terms/problem/missing-service", status_code=404, - title=_("Missing admin authentication service"), - detail=_("The specified admin authentication service does not exist."), -) - -MISSING_PATRON_AUTH_SERVICE = pd( - "http://librarysimplified.org/terms/problem/missing-patron-auth-service", - status_code=404, - title=_("Missing patron authentication service"), - detail=_("The specified patron authentication service does not exist."), + title=_("Missing service"), + detail=_("The specified service does not exist."), ) INVALID_CONFIGURATION_OPTION = pd( @@ -246,10 +239,3 @@ title=_("Missing sitewide setting value"), detail=_("A value is required to change a sitewide setting."), ) - -MISSING_METADATA_SERVICE = pd( - "http://librarysimplified.org/terms/problem/missing-metadata-service", - status_code=404, - title=_("Missing metadata service"), - detail=_("The specified metadata service does not exist."), -) diff --git a/api/admin/routes.py b/api/admin/routes.py index 1162975d8..af27e336e 100644 --- a/api/admin/routes.py +++ b/api/admin/routes.py @@ -351,6 +351,18 @@ def metadata_services(): return data return flask.jsonify(**data) +@app.route("/admin/analytics_services", methods=['GET', 'POST']) +@returns_problem_detail +@requires_admin +@requires_csrf_token +def analytics_services(): + data = app.manager.admin_settings_controller.analytics_services() + if isinstance(data, ProblemDetail): + return data + if isinstance(data, Response): + return data + return flask.jsonify(**data) + @app.route("/admin/sitewide_settings", methods=['GET', 'POST']) @returns_problem_detail @requires_admin @@ -383,6 +395,7 @@ def admin_sign_in_again(): @app.route('/admin/web/') # catchall for single-page URLs def admin_view(collection=None, book=None, **kwargs): setting_up = (app.manager.admin_sign_in_controller.auth == None) + home_url = None if not setting_up: admin = app.manager.admin_sign_in_controller.authenticated_admin_from_request() if isinstance(admin, ProblemDetail): @@ -404,8 +417,6 @@ def admin_view(collection=None, book=None, **kwargs): if libraries: library = libraries[0] home_url = app.manager.url_for('acquisition_groups', library_short_name=library.short_name) - else: - home_url = None csrf_token = flask.request.cookies.get("csrf_token") or app.manager.admin_sign_in_controller.generate_csrf_token() diff --git a/api/authenticator.py b/api/authenticator.py index 01e085025..24658d7df 100644 --- a/api/authenticator.py +++ b/api/authenticator.py @@ -20,7 +20,6 @@ json as pd_json, ) from core.util.opds_authentication_document import OPDSAuthenticationDocument -from core.analytics import Analytics from sqlalchemy.ext.hybrid import hybrid_property from problem_details import * from util.patron import PatronUtility @@ -247,7 +246,7 @@ def set_value(self, patron, field_name, value): value = None setattr(patron, field_name, value) - def get_or_create_patron(self, _db, library_id): + def get_or_create_patron(self, _db, library_id, analytics=None): """Create a Patron with this information. TODO: I'm concerned in the general case with race @@ -272,6 +271,9 @@ def get_or_create_patron(self, _db, library_id): :param library_id: Database ID of the Library with which this patron is associated. + + :param analytics: Analytics instance to track the new patron + creation event. """ # We must be very careful when checking whether the patron @@ -293,10 +295,10 @@ def get_or_create_patron(self, _db, library_id): __transaction = _db.begin_nested() patron, is_new = get_one_or_create(_db, Patron, **search_by) - if is_new: + if is_new and analytics: # Send out an analytics event to record the fact # that a new patron was created. - Analytics.collect_event(_db, None, + analytics.collect_event(patron.library, None, CirculationEvent.NEW_PATRON) # This makes sure the Patron is brought into sync with the @@ -344,11 +346,11 @@ class Authenticator(object): """Route requests to the appropriate LibraryAuthenticator. """ - def __init__(self, _db): + def __init__(self, _db, analytics=None): self.library_authenticators = {} for library in _db.query(Library): - self.library_authenticators[library.short_name] = LibraryAuthenticator.from_config(_db, library) + self.library_authenticators[library.short_name] = LibraryAuthenticator.from_config(_db, library, analytics) def invoke_authenticator_method(self, method_name, *args, **kwargs): short_name = flask.request.library.short_name @@ -374,7 +376,7 @@ class LibraryAuthenticator(object): """ @classmethod - def from_config(cls, _db, library): + def from_config(cls, _db, library, analytics=None): """Initialize an Authenticator for the given Library based on its configured ExternalIntegrations. """ @@ -393,7 +395,7 @@ def from_config(cls, _db, library): # AuthenticationProvider. for integration in integrations: try: - authenticator.register_provider(integration) + authenticator.register_provider(integration, analytics) except (ImportError, CannotLoadConfiguration), e: # These are the two types of error that might be caused # by misconfiguration, as opposed to bad code. @@ -459,7 +461,7 @@ def assert_ready_for_oauth(self): "OAuth providers are configured, but secret for signing bearer tokens is not." ) - def register_provider(self, integration): + def register_provider(self, integration, analytics=None): """Turn an ExternalIntegration object into an AuthenticationProvider object, and register it. @@ -489,7 +491,7 @@ def register_provider(self, integration): raise CannotLoadConfiguration( "Loaded module %s but could not find a class called AuthenticationProvider inside." % module_name ) - provider = provider_class(self.library, integration) + provider = provider_class(self.library, integration, analytics) if issubclass(provider_class, BasicAuthenticationProvider): self.register_basic_auth_provider(provider) # TODO: Run a self-test, or at least check that we have @@ -742,7 +744,7 @@ class AuthenticationProvider(object): } ] - def __init__(self, library, integration): + def __init__(self, library, integration, analytics=None): """Basic constructor. :param library: Patrons authenticated through this provider @@ -766,6 +768,7 @@ def __init__(self, library, integration): self.library_id = library.id self.log = logging.getLogger(self.NAME) + self.analytics = analytics # If there's a regular expression that maps authorization # identifier to external type, find it now. _db = Session.object_session(library) @@ -1025,7 +1028,7 @@ class BasicAuthenticationProvider(AuthenticationProvider): # indicates that no value should be used.) class_default = object() - def __init__(self, library, integration): + def __init__(self, library, integration, analytics=None): """Create a BasicAuthenticationProvider. :param library: Patrons authenticated through this provider @@ -1038,7 +1041,7 @@ def __init__(self, library, integration): object! It's associated with a scoped database session. Just pull normal Python objects out of it. """ - super(BasicAuthenticationProvider, self).__init__(library, integration) + super(BasicAuthenticationProvider, self).__init__(library, integration, analytics) identifier_regular_expression = integration.setting( self.IDENTIFIER_REGULAR_EXPRESSION ).value or self.DEFAULT_IDENTIFIER_REGULAR_EXPRESSION @@ -1147,7 +1150,7 @@ def authenticate(self, _db, credentials): # We have a PatronData from the ILS that does not # correspond to any local Patron. Create the local Patron. patron, is_new = patrondata.get_or_create_patron( - _db, self.library_id + _db, self.library_id, analytics=self.analytics ) # The lookup failed in the first place either because the @@ -1358,7 +1361,7 @@ def bearer_token_signing_secret(cls, _db): _db, cls.BEARER_TOKEN_SIGNING_SECRET ) - def __init__(self, library, integration): + def __init__(self, library, integration, analytics=None): """Initialize this OAuthAuthenticationProvider. :param library: Patrons authenticated through this provider @@ -1378,7 +1381,7 @@ def __init__(self, library, integration): process again. """ super(OAuthAuthenticationProvider, self).__init__( - library, integration + library, integration, analytics ) self.client_id = integration.username self.client_secret = integration.password @@ -1496,7 +1499,7 @@ def oauth_callback(self, _db, code): # Convert the PatronData into a Patron object. patron, is_new = patrondata.get_or_create_patron( - _db, self.library_id + _db, self.library_id, analytics=self.analytics ) # Create a credential for the Patron. diff --git a/api/axis.py b/api/axis.py index e2174b293..2d1ed7495 100644 --- a/api/axis.py +++ b/api/axis.py @@ -47,6 +47,7 @@ BibliographicCoverageProvider, CoverageFailure, ) +from core.analytics import Analytics from authenticator import Authenticator from circulation import ( @@ -232,7 +233,7 @@ def update_licensepools_for_identifiers(self, identifiers): licenses_reserved=0, patrons_in_hold_queue=0, ) - availability.apply(pool, ReplacementPolicy.from_license_source()) + availability.apply(pool, ReplacementPolicy.from_license_source(self._db)) class Axis360CirculationMonitor(CollectionMonitor): @@ -281,9 +282,10 @@ def run_once(self, start, cutoff): self._db.commit() def process_book(self, bibliographic, availability): - + + analytics = Analytics(self._db) license_pool, new_license_pool = availability.license_pool( - self._db, self.collection + self._db, self.collection, analytics ) edition, new_edition = bibliographic.edition(self._db) license_pool.edition = edition @@ -292,6 +294,7 @@ def process_book(self, bibliographic, availability): subjects=True, contributions=True, formats=True, + analytics=analytics, ) availability.apply(self._db, self.collection, replace=policy) if new_edition: diff --git a/api/bibliotheca.py b/api/bibliotheca.py index 835ef2f51..2ec671c5e 100644 --- a/api/bibliotheca.py +++ b/api/bibliotheca.py @@ -251,7 +251,7 @@ def release_hold(self, patron, pin, licensepool): else: raise CannotReleaseHold() - def apply_circulation_information_to_licensepool(self, circ, pool): + def apply_circulation_information_to_licensepool(self, circ, pool, analytics=None): """Apply the output of CirculationParser.process_one() to a LicensePool. @@ -271,7 +271,8 @@ def apply_circulation_information_to_licensepool(self, circ, pool): circ.get(LicensePool.licenses_owned, 0), circ.get(LicensePool.licenses_available, 0), circ.get(LicensePool.licenses_reserved, 0), - circ.get(LicensePool.patrons_in_hold_queue, 0) + circ.get(LicensePool.patrons_in_hold_queue, 0), + analytics, ) @@ -634,6 +635,7 @@ def __init__(self, collection, api_class=BibliothecaAPI, **kwargs): self.api = api_class else: self.api = api_class(collection) + self.analytics = Analytics(_db) def process_batch(self, identifiers): identifiers_by_bibliotheca_id = dict() @@ -667,12 +669,12 @@ def process_batch(self, identifiers): # Bibliotheca books are never open-access. pool.open_access = False - Analytics.collect_event( + self.analytics.collect_event( self._db, pool, CirculationEvent.DISTRIBUTOR_TITLE_ADD, now) else: [pool] = pools - self.api.apply_circulation_information_to_licensepool(circ, pool) + self.api.apply_circulation_information_to_licensepool(circ, pool, self.analytics) # At this point there may be some license pools left over # that Bibliotheca doesn't know about. This is a pretty reliable @@ -694,10 +696,7 @@ def process_batch(self, identifiers): "Removing unknown work %s from circulation.", identifier.identifier ) - pool.licenses_owned = 0 - pool.licenses_available = 0 - pool.licenses_reserved = 0 - pool.patrons_in_hold_queue = 0 + pool.update_availability(0, 0, 0, 0, self.analytics) pool.last_checked = now diff --git a/api/circulation.py b/api/circulation.py index f8250be58..26c34ef0f 100644 --- a/api/circulation.py +++ b/api/circulation.py @@ -9,7 +9,6 @@ from flask.ext.babel import lazy_gettext as _ from core.config import CannotLoadConfiguration -from core.analytics import Analytics from core.cdn import cdnify from core.model import ( get_one, @@ -172,7 +171,7 @@ class CirculationAPI(object): 'borrow'. """ - def __init__(self, _db, library, api_map=None): + def __init__(self, _db, library, analytics=None, api_map=None): """Constructor. :param _db: A database session (probably a scoped session, which is @@ -181,6 +180,9 @@ def __init__(self, _db, library, api_map=None): :param library: A Library object representing the library whose circulation we're concerned with. + :param analytics: An Analytics object for tracking + circulation events. + :param api_map: A dictionary mapping Collection protocols to API classes that should be instantiated to deal with these protocols. The default map will work fine unless you're a @@ -192,6 +194,7 @@ def __init__(self, _db, library, api_map=None): """ self._db = _db self.library_id = library.id + self.analytics = analytics self.initialization_exceptions = dict() api_map = api_map or self.default_api_map @@ -277,7 +280,7 @@ def borrow(self, patron, pin, licensepool, delivery_mechanism, __transaction = self._db.begin_nested() loan, is_new = licensepool.loan_to(patron, start=now, end=None) __transaction.commit() - self._collect_checkout_event(licensepool) + self._collect_checkout_event(patron, licensepool) return loan, None, is_new # Okay, it's not an open-access book. This means we need to go @@ -406,7 +409,7 @@ def borrow(self, patron, pin, licensepool, delivery_mechanism, # Send out an analytics event to record the fact that # a loan was initiated through the circulation # manager. - self._collect_checkout_event(licensepool) + self._collect_checkout_event(patron, licensepool) return loan, None, new_loan_record # At this point we know that we neither successfully @@ -437,12 +440,12 @@ def borrow(self, patron, pin, licensepool, delivery_mechanism, hold_info.hold_position ) - if hold and is_new: + if hold and is_new and self.analytics: # Send out an analytics event to record the fact that # a hold was initiated through the circulation # manager. - Analytics.collect_event( - self._db, licensepool, + self.analytics.collect_event( + patron.library, licensepool, CirculationEvent.CM_HOLD_PLACE, ) @@ -451,14 +454,15 @@ def borrow(self, patron, pin, licensepool, delivery_mechanism, __transaction.commit() return None, hold, is_new - def _collect_checkout_event(self, licensepool): + def _collect_checkout_event(self, patron, licensepool): """Collect an analytics event indicating the given LicensePool was checked out via the circulation manager. """ - Analytics.collect_event( - self._db, licensepool, - CirculationEvent.CM_CHECKOUT, - ) + if self.analytics: + self.analytics.collect_event( + patron.library, licensepool, + CirculationEvent.CM_CHECKOUT, + ) def fulfill(self, patron, pin, licensepool, delivery_mechanism, sync_on_failure=True): """Fulfil a book that a patron has previously checked out. @@ -514,10 +518,11 @@ def fulfill(self, patron, pin, licensepool, delivery_mechanism, sync_on_failure= # Send out an analytics event to record the fact that # a fulfillment was initiated through the circulation # manager. - Analytics.collect_event( - self._db, licensepool, - CirculationEvent.CM_FULFILL, - ) + if self.analytics: + self.analytics.collect_event( + patron.library, licensepool, + CirculationEvent.CM_FULFILL, + ) # Make sure the delivery mechanism we just used is associated # with the loan. @@ -581,10 +586,11 @@ def revoke_loan(self, patron, pin, licensepool): # Send out an analytics event to record the fact that # a loan was revoked through the circulation # manager. - Analytics.collect_event( - self._db, licensepool, - CirculationEvent.CM_CHECKIN, - ) + if self.analytics: + self.analytics.collect_event( + patron.library, licensepool, + CirculationEvent.CM_CHECKIN, + ) if not licensepool.open_access: api = self.api_for_license_pool(licensepool) @@ -622,10 +628,11 @@ def release_hold(self, patron, pin, licensepool): # Send out an analytics event to record the fact that # a hold was revoked through the circulation # manager. - Analytics.collect_event( - self._db, licensepool, - CirculationEvent.CM_HOLD_RELEASE, - ) + if self.analytics: + self.analytics.collect_event( + patron.library, licensepool, + CirculationEvent.CM_HOLD_RELEASE, + ) return True diff --git a/api/config.py b/api/config.py index ef51d8ad1..a826bee59 100644 --- a/api/config.py +++ b/api/config.py @@ -59,6 +59,10 @@ class Configuration(CoreConfiguration): "key": SECRET_KEY, "label": _("Internal secret key for admin interface cookies"), }, + { + "key": PATRON_WEB_CLIENT_URL, + "label": _("URL of the web catalog for patrons"), + }, ] LIBRARY_SETTINGS = CoreConfiguration.LIBRARY_SETTINGS + [ diff --git a/api/controller.py b/api/controller.py index f410bdd3e..1aa56dbd0 100644 --- a/api/controller.py +++ b/api/controller.py @@ -168,7 +168,8 @@ def load_settings(self): configuration after changes are made in the administrative interface. """ - self.auth = Authenticator(self._db) + self.analytics = Analytics(self._db) + self.auth = Authenticator(self._db, self.analytics) self.__external_search = None self.external_search_initialization_exception = None @@ -193,7 +194,7 @@ def load_settings(self): ) self.circulation_apis[library.id] = self.setup_circulation( - library + library, self.analytics ) authdata = self.setup_adobe_vendor_id(library) if authdata and not self.adobe_device_management: @@ -265,13 +266,13 @@ def setup_search(self): return None return search - def setup_circulation(self, library): + def setup_circulation(self, library, analytics): """Set up the Circulation object.""" if self.testing: cls = MockCirculationAPI else: cls = CirculationAPI - return cls(self._db, library) + return cls(self._db, library, analytics) def setup_one_time_controllers(self): """Set up all the controllers that will be used by the web app. @@ -1301,7 +1302,7 @@ def track_event(self, identifier_type, identifier, event_type): pools = self.load_licensepools(library, identifier_type, identifier) if isinstance(pools, ProblemDetail): return pools - Analytics.collect_event(self._db, pools[0], event_type, datetime.datetime.utcnow()) + self.manager.analytics.collect_event(library, pools[0], event_type, datetime.datetime.utcnow()) return Response({}, 200) else: return INVALID_ANALYTICS_EVENT_TYPE diff --git a/api/firstbook.py b/api/firstbook.py index 867e64978..30be0b64c 100644 --- a/api/firstbook.py +++ b/api/firstbook.py @@ -46,8 +46,8 @@ class FirstBookAuthenticationAPI(BasicAuthenticationProvider): log = logging.getLogger("First Book authentication API") - def __init__(self, library_id, integration): - super(FirstBookAuthenticationAPI, self).__init__(library_id, integration) + def __init__(self, library_id, integration, analytics=None): + super(FirstBookAuthenticationAPI, self).__init__(library_id, integration, analytics) url = integration.url key = integration.password if not (url and key): diff --git a/api/google_analytics_provider.py b/api/google_analytics_provider.py index 760178a9e..914429af8 100644 --- a/api/google_analytics_provider.py +++ b/api/google_analytics_provider.py @@ -1,22 +1,45 @@ -from config import Configuration +from config import CannotLoadConfiguration import uuid import unicodedata import urllib import re +from flask.ext.babel import lazy_gettext as _ from core.util.http import HTTP +from core.model import ( + ConfigurationSetting, + ExternalIntegration, + Session, + get_one, +) class GoogleAnalyticsProvider(object): - INTEGRATION_NAME = "Google Analytics" + NAME = _("Google Analytics") + + TRACKING_ID = "tracking_id" + DEFAULT_URL = "http://www.google-analytics.com/collect" + + SETTINGS = [ + { "key": ExternalIntegration.URL, "label": _("URL"), "default": DEFAULT_URL }, + ] + + LIBRARY_SETTINGS = [ + { "key": TRACKING_ID, "label": _("Tracking ID") }, + ] - @classmethod - def from_config(cls, config): - tracking_id = config[Configuration.INTEGRATIONS][cls.INTEGRATION_NAME]['tracking_id'] - return cls(tracking_id) + def __init__(self, integration, library=None): + _db = Session.object_session(integration) + if not library: + raise CannotLoadConfiguration("Google Analytics can't be configured without a library.") + url_setting = ConfigurationSetting.for_externalintegration(ExternalIntegration.URL, integration) + self.url = url_setting.value or self.DEFAULT_URL + self.tracking_id = ConfigurationSetting.for_library_and_externalintegration( + _db, self.TRACKING_ID, library, integration, + ).value + if not self.tracking_id: + raise CannotLoadConfiguration("Missing tracking id for library %s" % library.short_name) - def __init__(self, tracking_id): - self.tracking_id = tracking_id - def collect_event(self, _db, license_pool, event_type, time, **kwargs): + def collect_event(self, library, license_pool, event_type, time, **kwargs): client_id = uuid.uuid4() fields = { 'v': 1, @@ -54,7 +77,7 @@ def collect_event(self, _db, license_pool, event_type, time, **kwargs): fields = {k: unicodedata.normalize("NFKD", unicode(v)).encode("utf8") for k, v in fields.iteritems()} params = re.sub(r"=None(&?)", r"=\1", urllib.urlencode(fields)) - self.post("http://www.google-analytics.com/collect", params) + self.post(self.url, params) def post(self, url, params): response = HTTP.post_with_timeout(url, params) diff --git a/api/millenium_patron.py b/api/millenium_patron.py index 6ac722074..34269f8bc 100644 --- a/api/millenium_patron.py +++ b/api/millenium_patron.py @@ -89,8 +89,8 @@ class MilleniumPatronAPI(BasicAuthenticationProvider, XMLParser): } ] + BasicAuthenticationProvider.SETTINGS - def __init__(self, library, integration): - super(MilleniumPatronAPI, self).__init__(library, integration) + def __init__(self, library, integration, analytics=None): + super(MilleniumPatronAPI, self).__init__(library, integration, analytics) url = integration.url if not url: raise CannotLoadConfiguration( diff --git a/api/oneclick.py b/api/oneclick.py index dc92280ab..f000691c3 100644 --- a/api/oneclick.py +++ b/api/oneclick.py @@ -334,6 +334,7 @@ def update_licensepool_for_identifier(self, isbn, availability): subjects=True, contributions=True, formats=True, + analytics=Analytics(self._db), ) # licenses_available can be 0 or 999, depending on whether the book is @@ -787,6 +788,7 @@ def __init__(self, collection, batch_size=None, api_class=OneClickAPI, collection=self.collection, api_class=self.api, ) ) + self.analytics = Analytics(self._db) def process_availability(self, media_type='ebook'): # get list of all titles, with availability info @@ -800,8 +802,9 @@ def process_availability(self, media_type='ebook'): license_pool, is_new, is_changed = self.api.update_licensepool_for_identifier(isbn, available) # Log a circulation event for this work. if is_new: - Analytics.collect_event( - self._db, license_pool, CirculationEvent.DISTRIBUTOR_AVAILABILITY_NOTIFY, license_pool.last_checked) + for library in self.collection.libraries: + self.analytics.collect_event( + library, license_pool, CirculationEvent.DISTRIBUTOR_TITLE_ADD, license_pool.last_checked) item_count += 1 if item_count % self.batch_size == 0: diff --git a/api/overdrive.py b/api/overdrive.py index 0234503a3..4cd507dee 100644 --- a/api/overdrive.py +++ b/api/overdrive.py @@ -842,6 +842,7 @@ def __init__(self, _db, collection, api_class=OverdriveAPI): self.maximum_consecutive_unchanged_books = ( self.MAXIMUM_CONSECUTIVE_UNCHANGED_BOOKS ) + self.analytics = Analytics(_db) def recently_changed_ids(self, start, cutoff): return self.api.recently_changed_ids(start, cutoff) @@ -863,8 +864,9 @@ def run_once(self, start, cutoff): license_pool, is_new, is_changed = self.api.update_licensepool(book) # Log a circulation event for this work. if is_new: - Analytics.collect_event( - _db, license_pool, CirculationEvent.DISTRIBUTOR_TITLE_ADD, license_pool.last_checked) + for library in self.collection.libraries: + self.analytics.collect_event( + library, license_pool, CirculationEvent.DISTRIBUTOR_TITLE_ADD, license_pool.last_checked) _db.commit() diff --git a/api/simple_authentication.py b/api/simple_authentication.py index 5c88317e6..23cc99ec2 100644 --- a/api/simple_authentication.py +++ b/api/simple_authentication.py @@ -24,9 +24,9 @@ class SimpleAuthenticationProvider(BasicAuthenticationProvider): This is useful for testing a circulation manager before connecting it to an ILS.""") - def __init__(self, library, integration): + def __init__(self, library, integration, analytics=None): super(SimpleAuthenticationProvider, self).__init__( - library, integration, + library, integration, analytics ) self.test_identifier = integration.setting(self.TEST_IDENTIFIER).value self.test_password = integration.setting(self.TEST_PASSWORD).value diff --git a/api/sip/__init__.py b/api/sip/__init__.py index 8e3e2912f..dfd64931a 100644 --- a/api/sip/__init__.py +++ b/api/sip/__init__.py @@ -42,7 +42,7 @@ class SIP2AuthenticationProvider(BasicAuthenticationProvider): SIPClient.EXCESSIVE_FEES : PatronData.EXCESSIVE_FINES, } - def __init__(self, library, integration, client=None, connect=True): + def __init__(self, library, integration, analytics=None, client=None, connect=True): """An object capable of communicating with a SIP server. :param server: Hostname of the SIP server. @@ -77,7 +77,7 @@ def __init__(self, library, integration, client=None, connect=True): during testing. """ super(SIP2AuthenticationProvider, self).__init__( - library, integration + library, integration, analytics ) try: server = None diff --git a/core b/core index 49686f1d1..60b2de4e1 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 49686f1d119c93a8f1b0b945d0bf72a1e5b21264 +Subproject commit 60b2de4e198bb52745f2d6d0989fc69454d12e8a diff --git a/migration/20170616-2-move-third-party-config-to-external-integrations.py b/migration/20170616-2-move-third-party-config-to-external-integrations.py index 9ee5b15e8..1f40494d6 100755 --- a/migration/20170616-2-move-third-party-config-to-external-integrations.py +++ b/migration/20170616-2-move-third-party-config-to-external-integrations.py @@ -117,7 +117,7 @@ def log_import(integration_or_setting): library_url = adobe_conf.get('library_uri') ConfigurationSetting.for_library( - Library.WEBSITE_KEY, library).value = library_url + Configuration.WEBSITE_URL, library).value = library_url integration.libraries.append(library) @@ -145,6 +145,28 @@ def log_import(integration_or_setting): _db, Configuration.PATRON_WEB_CLIENT_URL) setting.value = patron_web_client_url log_import(setting) + + # Import analytics configuration. + policies = Configuration.get(u"policies", {}) + analytics_modules = policies.get(u"analytics", ["core.local_analytics_provider"]) + + if "api.google_analytics_provider" in analytics_modules: + google_analytics_conf = Configuration.integration(u"Google Analytics Provider", {}) + tracking_id = google_analytics_conf.get(u"tracking_id") + + integration = EI(protocol=u"api.google_analytics_provider", goal=EI.ANALYTICS_GOAL) + _db.add(integration) + integration.url = "http://www.google-analytics.com/collect" + + for library in LIBRARIES: + ConfigurationSetting.for_library_and_externalintegration( + _db, u"tracking_id", library, integration).value = tracking_id + library.integrations += [integration] + + if "core.local_analytics_provider" in analytics_modules: + integration = EI(protocol=u"core.local_analytics_provider", goal=EI.ANALYTICS_GOAL) + _db.add(integration) + finally: _db.commit() _db.close() diff --git a/tests/admin/test_controller.py b/tests/admin/test_controller.py index e93fb1f34..b756a657c 100644 --- a/tests/admin/test_controller.py +++ b/tests/admin/test_controller.py @@ -56,6 +56,9 @@ from api.novelist import NoveListAPI +from api.google_analytics_provider import GoogleAnalyticsProvider +from core.local_analytics_provider import LocalAnalyticsProvider + class AdminControllerTest(CirculationControllerTest): def setup(self): @@ -1359,9 +1362,10 @@ def test_collections_get_with_no_collections(self): response = self.manager.admin_settings_controller.collections() eq_(response.get("collections"), []) - # All the protocols in ExternalIntegration.LICENSE_PROTOCOLS are supported by the admin interface. - eq_(sorted([p.get("name") for p in response.get("protocols")]), - sorted(ExternalIntegration.LICENSE_PROTOCOLS)) + + names = [p.get("name") for p in response.get("protocols")] + assert ExternalIntegration.OVERDRIVE in names + assert ExternalIntegration.OPDS_IMPORT in names def test_collections_get_with_multiple_collections(self): @@ -1683,7 +1687,7 @@ def test_admin_auth_services_post_errors(self): ("id", "1234"), ]) response = self.manager.admin_settings_controller.admin_auth_services() - eq_(response, MISSING_ADMIN_AUTH_SERVICE) + eq_(response, MISSING_SERVICE) auth_service, ignore = create( self._db, ExternalIntegration, @@ -2000,7 +2004,7 @@ def test_patron_auth_services_post_errors(self): ("id", "123"), ]) response = self.manager.admin_settings_controller.patron_auth_services() - eq_(response, MISSING_PATRON_AUTH_SERVICE) + eq_(response, MISSING_SERVICE) auth_service, ignore = create( self._db, ExternalIntegration, @@ -2321,7 +2325,7 @@ def test_metadata_services_post_errors(self): ("id", "123"), ]) response = self.manager.admin_settings_controller.metadata_services() - eq_(response, MISSING_METADATA_SERVICE) + eq_(response, MISSING_SERVICE) service, ignore = create( self._db, ExternalIntegration, @@ -2436,3 +2440,207 @@ def test_metadata_services_post_edit(self): eq_("pass", novelist_service.password) eq_([l2], novelist_service.libraries) + def test_analytics_services_get_with_no_services(self): + with self.app.test_request_context("/"): + response = self.manager.admin_settings_controller.analytics_services() + eq_(response.get("analytics_services"), []) + protocols = response.get("protocols") + assert GoogleAnalyticsProvider.NAME in [p.get("label") for p in protocols] + assert "settings" in protocols[0] + + def test_analytics_services_get_with_one_service(self): + ga_service, ignore = create( + self._db, ExternalIntegration, + protocol=GoogleAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + ) + ga_service.url = self._str + + with self.app.test_request_context("/"): + response = self.manager.admin_settings_controller.analytics_services() + [service] = response.get("analytics_services") + + eq_(ga_service.id, service.get("id")) + eq_(ga_service.protocol, service.get("protocol")) + eq_(ga_service.url, service.get("settings").get(ExternalIntegration.URL)) + + ga_service.libraries += [self._default_library] + ConfigurationSetting.for_library_and_externalintegration( + self._db, GoogleAnalyticsProvider.TRACKING_ID, self._default_library, ga_service + ).value = "trackingid" + with self.app.test_request_context("/"): + response = self.manager.admin_settings_controller.analytics_services() + [service] = response.get("analytics_services") + + [library] = service.get("libraries") + eq_(self._default_library.short_name, library.get("short_name")) + eq_("trackingid", library.get(GoogleAnalyticsProvider.TRACKING_ID)) + + self._db.delete(ga_service) + + local_service, ignore = create( + self._db, ExternalIntegration, + protocol=LocalAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + ) + + local_service.libraries += [self._default_library] + with self.app.test_request_context("/"): + response = self.manager.admin_settings_controller.analytics_services() + [service] = response.get("analytics_services") + + eq_(local_service.id, service.get("id")) + eq_(local_service.protocol, service.get("protocol")) + [library] = service.get("libraries") + eq_(self._default_library.short_name, library.get("short_name")) + + def test_analytics_services_post_errors(self): + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("protocol", "Unknown"), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response, UNKNOWN_PROTOCOL) + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response, NO_PROTOCOL_FOR_NEW_SERVICE) + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("id", "123"), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response, MISSING_SERVICE) + + service, ignore = create( + self._db, ExternalIntegration, + protocol=GoogleAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + name="name", + ) + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("name", service.name), + ("protocol", GoogleAnalyticsProvider.__module__), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response, INTEGRATION_NAME_ALREADY_IN_USE) + + service, ignore = create( + self._db, ExternalIntegration, + protocol=GoogleAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + ) + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("id", service.id), + ("protocol", "core.local_analytics_provider"), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response, CANNOT_CHANGE_PROTOCOL) + + service, ignore = create( + self._db, ExternalIntegration, + protocol=GoogleAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + ) + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("id", service.id), + ("protocol", GoogleAnalyticsProvider.__module__), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response.uri, INCOMPLETE_CONFIGURATION.uri) + + service, ignore = create( + self._db, ExternalIntegration, + protocol=GoogleAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + ) + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("id", service.id), + ("protocol", GoogleAnalyticsProvider.__module__), + (ExternalIntegration.URL, "url"), + ("libraries", json.dumps([{"short_name": "not-a-library"}])), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response.uri, NO_SUCH_LIBRARY.uri) + + service, ignore = create( + self._db, ExternalIntegration, + protocol=GoogleAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + ) + library, ignore = create( + self._db, Library, name="Library", short_name="L", + ) + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("id", service.id), + ("protocol", GoogleAnalyticsProvider.__module__), + (ExternalIntegration.URL, "url"), + ("libraries", json.dumps([{"short_name": library.short_name}])), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response.uri, INCOMPLETE_CONFIGURATION.uri) + + def test_analytics_services_post_create(self): + library, ignore = create( + self._db, Library, name="Library", short_name="L", + ) + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("protocol", GoogleAnalyticsProvider.__module__), + (ExternalIntegration.URL, "url"), + ("libraries", json.dumps([{"short_name": "L", "tracking_id": "trackingid"}])), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response.status_code, 201) + + service = get_one(self._db, ExternalIntegration, goal=ExternalIntegration.ANALYTICS_GOAL) + eq_(GoogleAnalyticsProvider.__module__, service.protocol) + eq_("url", service.url) + eq_([library], service.libraries) + eq_("trackingid", ConfigurationSetting.for_library_and_externalintegration( + self._db, GoogleAnalyticsProvider.TRACKING_ID, library, service).value) + + def test_analytics_services_post_edit(self): + l1, ignore = create( + self._db, Library, name="Library 1", short_name="L1", + ) + l2, ignore = create( + self._db, Library, name="Library 2", short_name="L2", + ) + + ga_service, ignore = create( + self._db, ExternalIntegration, + protocol=GoogleAnalyticsProvider.__module__, + goal=ExternalIntegration.ANALYTICS_GOAL, + ) + ga_service.url = "oldurl" + ga_service.libraries = [l1] + + with self.app.test_request_context("/", method="POST"): + flask.request.form = MultiDict([ + ("id", ga_service.id), + ("protocol", GoogleAnalyticsProvider.__module__), + (ExternalIntegration.URL, "url"), + ("libraries", json.dumps([{"short_name": "L2", "tracking_id": "l2id"}])), + ]) + response = self.manager.admin_settings_controller.analytics_services() + eq_(response.status_code, 200) + + eq_(GoogleAnalyticsProvider.__module__, ga_service.protocol) + eq_("url", ga_service.url) + eq_([l2], ga_service.libraries) + eq_("l2id", ConfigurationSetting.for_library_and_externalintegration( + self._db, GoogleAnalyticsProvider.TRACKING_ID, l2, ga_service).value) + diff --git a/tests/test_authenticator.py b/tests/test_authenticator.py index f5c01f446..1cb43565a 100644 --- a/tests/test_authenticator.py +++ b/tests/test_authenticator.py @@ -37,7 +37,6 @@ from core.util.opds_authentication_document import ( OPDSAuthenticationDocument, ) -from core.analytics import Analytics from core.mock_analytics_provider import MockAnalyticsProvider from api.millenium_patron import MilleniumPatronAPI @@ -89,9 +88,9 @@ class MockBasicAuthenticationProvider( """A mock basic authentication provider for use in testing the overall authentication process. """ - def __init__(self, library_id, integration, patron=None, patrondata=None, *args, **kwargs): + def __init__(self, library_id, integration, analytics=None, patron=None, patrondata=None, *args, **kwargs): super(MockBasicAuthenticationProvider, self).__init__( - library_id, integration, *args, **kwargs) + library_id, integration, analytics, *args, **kwargs) self.patron = patron self.patrondata = patrondata @@ -109,10 +108,10 @@ class MockBasic(BasicAuthenticationProvider): the workflow around Basic Auth. """ NAME = 'Mock Basic Auth provider' - def __init__(self, library_id, integration, patrondata=None, + def __init__(self, library_id, integration, analytics=None, patrondata=None, remote_patron_lookup_patrondata=None, *args, **kwargs): - super(MockBasic, self).__init__(library_id, integration, *args, **kwargs) + super(MockBasic, self).__init__(library_id, integration, analytics) self.patrondata = patrondata self.remote_patron_lookup_patrondata = remote_patron_lookup_patrondata @@ -149,10 +148,10 @@ class MockOAuth(OAuthAuthenticationProvider): TOKEN_TYPE = "test token" TOKEN_DATA_SOURCE_NAME = DataSource.MANUAL - def __init__(self, library, name="Mock OAuth", integration=None): + def __init__(self, library, name="Mock OAuth", integration=None, analytics=None): _db = Session.object_session(library) integration = integration or self._mock_integration(_db, name) - super(MockOAuth, self).__init__(library, integration) + super(MockOAuth, self).__init__(library, integration, analytics) @classmethod def _mock_integration(self, _db, name): @@ -352,37 +351,27 @@ def test_apply_on_incomplete_information(self): eq_(None, patron.last_external_sync) def test_get_or_create_patron(self): - config = { - Configuration.POLICIES: { - Configuration.ANALYTICS_POLICY: ["core.mock_analytics_provider"] - } - } - with temp_config(config) as config: - provider = MockAnalyticsProvider() - analytics = Analytics.initialize( - ['core.mock_analytics_provider'], config - ) - mock = Analytics.instance().providers[0] + analytics = MockAnalyticsProvider() - # The patron didn't exist yet, so it was created - # and an analytics event was sent. - patron, is_new = self.data.get_or_create_patron( - self._db, self._default_library.id - ) - eq_('2', patron.authorization_identifier) - eq_(self._default_library, patron.library) - eq_(True, is_new) - eq_(CirculationEvent.NEW_PATRON, mock.event_type) - eq_(1, mock.count) - - # The same patron is returned, and no analytics - # event was sent. - patron, is_new = self.data.get_or_create_patron( - self._db, self._default_library.id - ) - eq_('2', patron.authorization_identifier) - eq_(False, is_new) - eq_(1, mock.count) + # The patron didn't exist yet, so it was created + # and an analytics event was sent. + patron, is_new = self.data.get_or_create_patron( + self._db, self._default_library.id, analytics + ) + eq_('2', patron.authorization_identifier) + eq_(self._default_library, patron.library) + eq_(True, is_new) + eq_(CirculationEvent.NEW_PATRON, analytics.event_type) + eq_(1, analytics.count) + + # The same patron is returned, and no analytics + # event was sent. + patron, is_new = self.data.get_or_create_patron( + self._db, self._default_library.id, analytics + ) + eq_('2', patron.authorization_identifier) + eq_(False, is_new) + eq_(1, analytics.count) def test_to_response_parameters(self): @@ -406,8 +395,10 @@ def test_init(self): l2.integrations.append(integration) self._db.commit() + + analytics = MockAnalyticsProvider() - auth = Authenticator(self._db) + auth = Authenticator(self._db, analytics) # A LibraryAuthenticator has been created for each Library. assert 'l1' in auth.library_authenticators @@ -427,6 +418,10 @@ def test_init(self): MilleniumPatronAPI ) + # Each provider has the analytics set. + eq_(analytics, auth.library_authenticators['l1'].basic_auth_provider.analytics) + eq_(analytics, auth.library_authenticators['l2'].basic_auth_provider.analytics) + def test_methods_call_library_authenticators(self): class MockLibraryAuthenticator(LibraryAuthenticator): def __init__(self, name): @@ -505,17 +500,20 @@ def test_from_config_basic_auth_and_oauth(self): oauth.password = "client_secret" library.integrations.append(oauth) - auth = LibraryAuthenticator.from_config(self._db, library) + analytics = MockAnalyticsProvider() + auth = LibraryAuthenticator.from_config(self._db, library, analytics) assert auth.basic_auth_provider != None assert isinstance(auth.basic_auth_provider, FirstBookAuthenticationAPI) + eq_(analytics, auth.basic_auth_provider.analytics) eq_(1, len(auth.oauth_providers_by_name)) clever = auth.oauth_providers_by_name[ CleverAuthenticationAPI.NAME ] assert isinstance(clever, CleverAuthenticationAPI) + eq_(analytics, clever.analytics) def test_config_succeeds_when_no_providers_configured(self): """You can call from_config even when there are no authentication @@ -1439,7 +1437,7 @@ class TestBasicAuthenticationProviderAuthenticate(AuthenticatorTest): def test_success(self): patron = self._patron() patrondata = PatronData(permanent_id=patron.external_identifier) - provider = self.mock_basic(patrondata) + provider = self.mock_basic(patrondata=patrondata) # authenticate() calls remote_authenticate(), which returns the # queued up PatronData object. The corresponding Patron is then @@ -1453,14 +1451,14 @@ def test_success(self): def test_failure_when_remote_authentication_returns_problemdetail(self): patron = self._patron() patrondata = PatronData(permanent_id=patron.external_identifier) - provider = self.mock_basic(UNSUPPORTED_AUTHENTICATION_MECHANISM) + provider = self.mock_basic(patrondata=UNSUPPORTED_AUTHENTICATION_MECHANISM) eq_(UNSUPPORTED_AUTHENTICATION_MECHANISM, provider.authenticate(self._db, self.credentials)) def test_failure_when_remote_authentication_returns_none(self): patron = self._patron() patrondata = PatronData(permanent_id=patron.external_identifier) - provider = self.mock_basic(None) + provider = self.mock_basic(patrondata=None) eq_(None, provider.authenticate(self._db, self.credentials)) @@ -1490,7 +1488,7 @@ def test_authentication_succeeds_but_patronlookup_fails(self): authentication provider. But we handle it. """ patrondata = PatronData(permanent_id=self._str) - provider = self.mock_basic(patrondata) + provider = self.mock_basic(patrondata=patrondata) # When we call remote_authenticate(), we get patrondata, but # there is no corresponding local patron, so we call @@ -1512,7 +1510,7 @@ def test_authentication_creates_missing_patron(self): integration = self._external_integration( self._str, ExternalIntegration.PATRON_AUTH_GOAL ) - provider = MockBasic(library, integration, patrondata, patrondata) + provider = MockBasic(library, integration, patrondata=patrondata, remote_patron_lookup_patrondata=patrondata) patron = provider.authenticate(self._db, self.credentials) # A server side Patron was created from the PatronData. @@ -1548,7 +1546,7 @@ def test_authentication_updates_outdated_patron_on_permanent_id_match(self): username=new_username, ) - provider = self.mock_basic(patrondata) + provider = self.mock_basic(patrondata=patrondata) patron2 = provider.authenticate(self._db, self.credentials) # We were able to match our local patron to the patron held by the @@ -1577,7 +1575,7 @@ def test_authentication_updates_outdated_patron_on_username_match(self): username=username, ) - provider = self.mock_basic(patrondata) + provider = self.mock_basic(patrondata=patrondata) patron2 = provider.authenticate(self._db, self.credentials) # We were able to match our local patron to the patron held by the @@ -1606,7 +1604,7 @@ def test_authentication_updates_outdated_patron_on_authorization_identifier_matc username=new_username, ) - provider = self.mock_basic(patrondata) + provider = self.mock_basic(patrondata=patrondata) patron2 = provider.authenticate(self._db, self.credentials) # We were able to match our local patron to the patron held by the diff --git a/tests/test_axis.py b/tests/test_axis.py index eaa4f7fdb..33c3cae74 100644 --- a/tests/test_axis.py +++ b/tests/test_axis.py @@ -12,10 +12,12 @@ ConfigurationSetting, DataSource, Edition, + ExternalIntegration, Identifier, Subject, Contributor, LicensePool, + create, ) from core.metadata_layer import ( @@ -179,71 +181,68 @@ class TestCirculationMonitor(Axis360Test): ) def test_process_book(self): - config = { - Configuration.POLICIES: { - Configuration.ANALYTICS_POLICY: ["core.local_analytics_provider"] - } - } - with temp_config() as config: - Analytics.initialize( - ['core.local_analytics_provider'], config - ) - monitor = Axis360CirculationMonitor( - self.collection, api_class=MockAxis360API, - metadata_client=MockMetadataWranglerOPDSLookup('url') - ) - edition, license_pool = monitor.process_book( - self.BIBLIOGRAPHIC_DATA, self.AVAILABILITY_DATA) - eq_(u'Faith of My Fathers : A Family Memoir', edition.title) - eq_(u'eng', edition.language) - eq_(u'Random House Inc', edition.publisher) - eq_(u'Random House Inc2', edition.imprint) - - eq_(Identifier.AXIS_360_ID, edition.primary_identifier.type) - eq_(u'0003642860', edition.primary_identifier.identifier) - - [isbn] = [x for x in edition.equivalent_identifiers() - if x is not edition.primary_identifier] - eq_(Identifier.ISBN, isbn.type) - eq_(u'9780375504587', isbn.identifier) - - eq_(["McCain, John", "Salter, Mark"], - sorted([x.sort_name for x in edition.contributors]), - ) - - subs = sorted( - (x.subject.type, x.subject.identifier) - for x in edition.primary_identifier.classifications - ) - eq_([(Subject.BISAC, u'BIOGRAPHY & AUTOBIOGRAPHY / Political'), - (Subject.FREEFORM_AUDIENCE, u'Adult')], subs) - - eq_(9, license_pool.licenses_owned) - eq_(8, license_pool.licenses_available) - eq_(0, license_pool.patrons_in_hold_queue) - eq_(datetime.datetime(2015, 5, 20, 2, 9, 8), license_pool.last_checked) - - # Three circulation events were created, backdated to the - # last_checked date of the license pool. - events = license_pool.circulation_events - eq_([u'distributor_title_add', u'distributor_check_in', u'distributor_license_add'], - [x.type for x in events]) - for e in events: - eq_(e.start, license_pool.last_checked) - - # A presentation-ready work has been created for the LicensePool. - work = license_pool.work - eq_(True, work.presentation_ready) - eq_("Faith of My Fathers : A Family Memoir", work.title) - - # A CoverageRecord has been provided for this book in the Axis - # 360 bibliographic coverage provider, so that in the future - # it doesn't have to make a separate API request to ask about - # this book. - records = [x for x in license_pool.identifier.coverage_records - if x.data_source.name == DataSource.AXIS_360 - and x.operation is None] - eq_(1, len(records)) + integration, ignore = create( + self._db, ExternalIntegration, + goal=ExternalIntegration.ANALYTICS_GOAL, + protocol="core.local_analytics_provider", + ) + + monitor = Axis360CirculationMonitor( + self.collection, api_class=MockAxis360API, + metadata_client=MockMetadataWranglerOPDSLookup('url') + ) + edition, license_pool = monitor.process_book( + self.BIBLIOGRAPHIC_DATA, self.AVAILABILITY_DATA) + eq_(u'Faith of My Fathers : A Family Memoir', edition.title) + eq_(u'eng', edition.language) + eq_(u'Random House Inc', edition.publisher) + eq_(u'Random House Inc2', edition.imprint) + + eq_(Identifier.AXIS_360_ID, edition.primary_identifier.type) + eq_(u'0003642860', edition.primary_identifier.identifier) + + [isbn] = [x for x in edition.equivalent_identifiers() + if x is not edition.primary_identifier] + eq_(Identifier.ISBN, isbn.type) + eq_(u'9780375504587', isbn.identifier) + + eq_(["McCain, John", "Salter, Mark"], + sorted([x.sort_name for x in edition.contributors]), + ) + + subs = sorted( + (x.subject.type, x.subject.identifier) + for x in edition.primary_identifier.classifications + ) + eq_([(Subject.BISAC, u'BIOGRAPHY & AUTOBIOGRAPHY / Political'), + (Subject.FREEFORM_AUDIENCE, u'Adult')], subs) + + eq_(9, license_pool.licenses_owned) + eq_(8, license_pool.licenses_available) + eq_(0, license_pool.patrons_in_hold_queue) + eq_(datetime.datetime(2015, 5, 20, 2, 9, 8), license_pool.last_checked) + + # Three circulation events were created, backdated to the + # last_checked date of the license pool. + events = license_pool.circulation_events + eq_([u'distributor_title_add', u'distributor_check_in', u'distributor_license_add'], + [x.type for x in events]) + for e in events: + eq_(e.start, license_pool.last_checked) + + # A presentation-ready work has been created for the LicensePool. + work = license_pool.work + eq_(True, work.presentation_ready) + eq_("Faith of My Fathers : A Family Memoir", work.title) + + # A CoverageRecord has been provided for this book in the Axis + # 360 bibliographic coverage provider, so that in the future + # it doesn't have to make a separate API request to ask about + # this book. + records = [x for x in license_pool.identifier.coverage_records + if x.data_source.name == DataSource.AXIS_360 + and x.operation is None] + eq_(1, len(records)) def test_process_book_updates_old_licensepool(self): """If the LicensePool already exists, the circulation monitor diff --git a/tests/test_bibliotheca.py b/tests/test_bibliotheca.py index 6c6f2bebe..70b56d0f2 100644 --- a/tests/test_bibliotheca.py +++ b/tests/test_bibliotheca.py @@ -19,12 +19,14 @@ Contributor, DataSource, Edition, + ExternalIntegration, Hold, Identifier, LicensePool, Loan, Resource, - Timestamp + Timestamp, + create, ) from core.util.http import ( BadResponseException, @@ -102,6 +104,14 @@ def test_update_availability(self): method defined by the CirculationAPI interface. """ + # Create an analytics integration so we can make sure + # events are tracked. + integration, ignore = create( + self._db, ExternalIntegration, + goal=ExternalIntegration.ANALYTICS_GOAL, + protocol="core.local_analytics_provider", + ) + # Create a LicensePool that needs updating. edition, pool = self._edition( identifier_type=Identifier.THREEM_ID, @@ -134,6 +144,14 @@ def test_update_availability(self): eq_(1, pool.licenses_available) eq_(0, pool.patrons_in_hold_queue) + circulation_events = self._db.query(CirculationEvent).join(LicensePool).filter(LicensePool.id==pool.id) + eq_(3, circulation_events.count()) + types = [e.type for e in circulation_events] + eq_(sorted([CirculationEvent.DISTRIBUTOR_LICENSE_REMOVE, + CirculationEvent.DISTRIBUTOR_CHECKOUT, + CirculationEvent.DISTRIBUTOR_HOLD_RELEASE]), + sorted(types)) + old_last_checked = pool.last_checked assert old_last_checked is not None @@ -142,6 +160,7 @@ def test_update_availability(self): # collection. data = self.sample_data("empty_item_circulation.xml") self.api.queue_response(200, content=data) + self.api.update_availability(pool) eq_(0, pool.licenses_owned) @@ -150,6 +169,9 @@ def test_update_availability(self): assert pool.last_checked is not old_last_checked + circulation_events = self._db.query(CirculationEvent).join(LicensePool).filter(LicensePool.id==pool.id) + eq_(5, circulation_events.count()) + def test_sync_bookshelf(self): patron = self._patron() circulation = CirculationAPI(self._db, self._default_library, api_map={ diff --git a/tests/test_circulationapi.py b/tests/test_circulationapi.py index 5fd43c1d3..61b0d653b 100644 --- a/tests/test_circulationapi.py +++ b/tests/test_circulationapi.py @@ -24,7 +24,6 @@ HoldInfo, ) -from core.analytics import Analytics from core.config import CannotLoadConfiguration from core.model import ( CirculationEvent, @@ -62,8 +61,9 @@ def setup(self): self.identifier = self.pool.identifier [self.delivery_mechanism] = self.pool.delivery_mechanisms self.patron = self._patron() + self.analytics = MockAnalyticsProvider() self.circulation = MockCirculationAPI( - self._db, self._default_library, api_map = { + self._db, self._default_library, analytics=self.analytics, api_map = { ExternalIntegration.BIBLIOTHECA : MockBibliothecaAPI } ) @@ -90,54 +90,43 @@ def test_borrow_sends_analytics_event(self): self.remote.queue_checkout(loaninfo) now = datetime.utcnow() - config = { - Configuration.POLICIES: { - Configuration.ANALYTICS_POLICY: ["core.mock_analytics_provider"] - } - } - with temp_config(config) as config: - provider = MockAnalyticsProvider() - analytics = Analytics.initialize( - ['core.mock_analytics_provider'], config - ) - loan, hold, is_new = self.borrow() - - # The Loan looks good. - eq_(loaninfo.identifier, loan.license_pool.identifier.identifier) - eq_(self.patron, loan.patron) - eq_(None, hold) - eq_(True, is_new) - - # An analytics event was created. - mock = Analytics.instance().providers[0] - eq_(1, mock.count) - eq_(CirculationEvent.CM_CHECKOUT, - mock.event_type) + loan, hold, is_new = self.borrow() + + # The Loan looks good. + eq_(loaninfo.identifier, loan.license_pool.identifier.identifier) + eq_(self.patron, loan.patron) + eq_(None, hold) + eq_(True, is_new) + + # An analytics event was created. + eq_(1, self.analytics.count) + eq_(CirculationEvent.CM_CHECKOUT, + self.analytics.event_type) - # Try to 'borrow' the same book again. - self.remote.queue_checkout(AlreadyCheckedOut()) - loan, hold, is_new = self.borrow() - eq_(False, is_new) - - # Since the loan already existed, no new analytics event was - # sent. - eq_(1, mock.count) + # Try to 'borrow' the same book again. + self.remote.queue_checkout(AlreadyCheckedOut()) + loan, hold, is_new = self.borrow() + eq_(False, is_new) + + # Since the loan already existed, no new analytics event was + # sent. + eq_(1, self.analytics.count) - # Now try to renew the book. - self.remote.queue_checkout(loaninfo) - loan, hold, is_new = self.borrow() - eq_(False, is_new) - - # Renewals are counted as loans, since from an accounting - # perspective they _are_ loans. - eq_(2, mock.count) - - # Loans of open-access books go through a different code - # path, but they count as loans nonetheless. - self.pool.open_access = True - self.remote.queue_checkout(loaninfo) - loan, hold, is_new = self.borrow() - eq_(3, mock.count) + # Now try to renew the book. + self.remote.queue_checkout(loaninfo) + loan, hold, is_new = self.borrow() + eq_(False, is_new) + + # Renewals are counted as loans, since from an accounting + # perspective they _are_ loans. + eq_(2, self.analytics.count) + + # Loans of open-access books go through a different code + # path, but they count as loans nonetheless. + self.pool.open_access = True + self.remote.queue_checkout(loaninfo) + loan, hold, is_new = self.borrow() + eq_(3, self.analytics.count) def test_attempt_borrow_with_existing_remote_loan(self): """The patron has a remote loan that the circ manager doesn't know @@ -271,38 +260,27 @@ def test_hold_sends_analytics_event(self): ) self.remote.queue_hold(holdinfo) - config = { - Configuration.POLICIES: { - Configuration.ANALYTICS_POLICY: ["core.mock_analytics_provider"] - } - } - with temp_config(config) as config: - provider = MockAnalyticsProvider() - analytics = Analytics.initialize( - ['core.mock_analytics_provider'], config - ) - loan, hold, is_new = self.borrow() - - # The Hold looks good. - eq_(holdinfo.identifier, hold.license_pool.identifier.identifier) - eq_(self.patron, hold.patron) - eq_(None, loan) - eq_(True, is_new) - - # An analytics event was created. - mock = Analytics.instance().providers[0] - eq_(1, mock.count) - eq_(CirculationEvent.CM_HOLD_PLACE, - mock.event_type) - - # Try to 'borrow' the same book again. - self.remote.queue_checkout(AlreadyOnHold()) - loan, hold, is_new = self.borrow() - eq_(False, is_new) - - # Since the hold already existed, no new analytics event was - # sent. - eq_(1, mock.count) + loan, hold, is_new = self.borrow() + + # The Hold looks good. + eq_(holdinfo.identifier, hold.license_pool.identifier.identifier) + eq_(self.patron, hold.patron) + eq_(None, loan) + eq_(True, is_new) + + # An analytics event was created. + eq_(1, self.analytics.count) + eq_(CirculationEvent.CM_HOLD_PLACE, + self.analytics.event_type) + + # Try to 'borrow' the same book again. + self.remote.queue_checkout(AlreadyOnHold()) + loan, hold, is_new = self.borrow() + eq_(False, is_new) + + # Since the hold already existed, no new analytics event was + # sent. + eq_(1, self.analytics.count) def test_loan_becomes_hold_if_no_available_copies_and_preexisting_loan(self): # Once upon a time, we had a loan for this book. @@ -504,75 +482,42 @@ def test_fulfill_sends_analytics_event(self): fulfillment.content_link = None self.remote.queue_fulfill(fulfillment) - config = { - Configuration.POLICIES: { - Configuration.ANALYTICS_POLICY: ["core.mock_analytics_provider"] - } - } - with temp_config(config) as config: - provider = MockAnalyticsProvider() - analytics = Analytics.initialize( - ['core.mock_analytics_provider'], config - ) - result = self.circulation.fulfill(self.patron, '1234', self.pool, - self.pool.delivery_mechanisms[0]) - - # The fulfillment looks good. - eq_(fulfillment, result) - - # An analytics event was created. - mock = Analytics.instance().providers[0] - eq_(1, mock.count) - eq_(CirculationEvent.CM_FULFILL, - mock.event_type) + result = self.circulation.fulfill(self.patron, '1234', self.pool, + self.pool.delivery_mechanisms[0]) + + # The fulfillment looks good. + eq_(fulfillment, result) + + # An analytics event was created. + eq_(1, self.analytics.count) + eq_(CirculationEvent.CM_FULFILL, + self.analytics.event_type) def test_revoke_loan_sends_analytics_event(self): self.pool.loan_to(self.patron) self.remote.queue_checkin(True) - config = { - Configuration.POLICIES: { - Configuration.ANALYTICS_POLICY: ["core.mock_analytics_provider"] - } - } - with temp_config(config) as config: - provider = MockAnalyticsProvider() - analytics = Analytics.initialize( - ['core.mock_analytics_provider'], config - ) - result = self.circulation.revoke_loan(self.patron, '1234', self.pool) - - eq_(True, result) - - # An analytics event was created. - mock = Analytics.instance().providers[0] - eq_(1, mock.count) - eq_(CirculationEvent.CM_CHECKIN, - mock.event_type) + result = self.circulation.revoke_loan(self.patron, '1234', self.pool) + + eq_(True, result) + + # An analytics event was created. + eq_(1, self.analytics.count) + eq_(CirculationEvent.CM_CHECKIN, + self.analytics.event_type) def test_release_hold_sends_analytics_event(self): self.pool.on_hold_to(self.patron) self.remote.queue_release_hold(True) - config = { - Configuration.POLICIES: { - Configuration.ANALYTICS_POLICY: ["core.mock_analytics_provider"] - } - } - with temp_config(config) as config: - provider = MockAnalyticsProvider() - analytics = Analytics.initialize( - ['core.mock_analytics_provider'], config - ) - result = self.circulation.release_hold(self.patron, '1234', self.pool) - - eq_(True, result) - - # An analytics event was created. - mock = Analytics.instance().providers[0] - eq_(1, mock.count) - eq_(CirculationEvent.CM_HOLD_RELEASE, - mock.event_type) + result = self.circulation.release_hold(self.patron, '1234', self.pool) + + eq_(True, result) + + # An analytics event was created. + eq_(1, self.analytics.count) + eq_(CirculationEvent.CM_HOLD_RELEASE, + self.analytics.event_type) def test_sync_bookshelf_ignores_local_loan_with_no_identifier(self): loan, ignore = self.pool.loan_to(self.patron) diff --git a/tests/test_controller.py b/tests/test_controller.py index 520275143..2261c618c 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -2343,32 +2343,28 @@ def setup(self): self.identifier = self.lp.identifier def test_track_event(self): - with temp_config() as config: - config = { - Configuration.POLICIES : { - Configuration.ANALYTICS_POLICY : ["core.local_analytics_provider"], - } - } - - analytics = Analytics.initialize( - ['core.local_analytics_provider'], config - ) - - with self.request_context_with_library("/"): - response = self.manager.analytics_controller.track_event(self.identifier.type, self.identifier.identifier, "invalid_type") - eq_(400, response.status_code) - eq_(INVALID_ANALYTICS_EVENT_TYPE.uri, response.uri) + integration, ignore = create( + self._db, ExternalIntegration, + goal=ExternalIntegration.ANALYTICS_GOAL, + protocol="core.local_analytics_provider", + ) + self.manager.analytics = Analytics(self._db) - with self.request_context_with_library("/"): - response = self.manager.analytics_controller.track_event(self.identifier.type, self.identifier.identifier, "open_book") - eq_(200, response.status_code) + with self.request_context_with_library("/"): + response = self.manager.analytics_controller.track_event(self.identifier.type, self.identifier.identifier, "invalid_type") + eq_(400, response.status_code) + eq_(INVALID_ANALYTICS_EVENT_TYPE.uri, response.uri) - circulation_event = get_one( - self._db, CirculationEvent, - type="open_book", - license_pool=self.lp - ) - assert circulation_event != None + with self.request_context_with_library("/"): + response = self.manager.analytics_controller.track_event(self.identifier.type, self.identifier.identifier, "open_book") + eq_(200, response.status_code) + + circulation_event = get_one( + self._db, CirculationEvent, + type="open_book", + license_pool=self.lp + ) + assert circulation_event != None class TestDeviceManagementProtocolController(ControllerTest): diff --git a/tests/test_google_analytics_provider.py b/tests/test_google_analytics_provider.py index 0c5610076..b3a00c771 100644 --- a/tests/test_google_analytics_provider.py +++ b/tests/test_google_analytics_provider.py @@ -1,18 +1,21 @@ from nose.tools import ( eq_, - set_trace + set_trace, + assert_raises_regexp, ) from api.config import ( - Configuration, - temp_config, + CannotLoadConfiguration, ) from core.analytics import Analytics from api.google_analytics_provider import GoogleAnalyticsProvider from . import DatabaseTest from core.model import ( get_one_or_create, + create, CirculationEvent, + ConfigurationSetting, DataSource, + ExternalIntegration, LicensePool ) import unicodedata @@ -29,19 +32,49 @@ def post(self, url, params): class TestGoogleAnalyticsProvider(DatabaseTest): - def test_from_config(self): - config = { - Configuration.INTEGRATIONS: { - GoogleAnalyticsProvider.INTEGRATION_NAME: { - "tracking_id": "faketrackingid" - } - } - } - ga = GoogleAnalyticsProvider.from_config(config) + def test_init(self): + integration, ignore = create( + self._db, ExternalIntegration, + goal=ExternalIntegration.ANALYTICS_GOAL, + protocol="api.google_analytics_provider", + ) + + assert_raises_regexp( + CannotLoadConfiguration, + "Google Analytics can't be configured without a library.", + GoogleAnalyticsProvider, integration + ) + + assert_raises_regexp( + CannotLoadConfiguration, + "Missing tracking id for library %s" % self._default_library.short_name, + GoogleAnalyticsProvider, integration, self._default_library + ) + + ConfigurationSetting.for_library_and_externalintegration( + self._db, GoogleAnalyticsProvider.TRACKING_ID, self._default_library, integration + ).value = "faketrackingid" + ga = GoogleAnalyticsProvider(integration, self._default_library) + eq_(GoogleAnalyticsProvider.DEFAULT_URL, ga.url) + eq_("faketrackingid", ga.tracking_id) + + integration.url = self._str + ga = GoogleAnalyticsProvider(integration, self._default_library) + eq_(integration.url, ga.url) eq_("faketrackingid", ga.tracking_id) def test_collect_event_with_work(self): - ga = MockGoogleAnalyticsProvider("faketrackingid") + integration, ignore = create( + self._db, ExternalIntegration, + goal=ExternalIntegration.ANALYTICS_GOAL, + protocol="api.google_analytics_provider", + ) + integration.url = self._str + ConfigurationSetting.for_library_and_externalintegration( + self._db, GoogleAnalyticsProvider.TRACKING_ID, self._default_library, integration + ).value = "faketrackingid" + ga = MockGoogleAnalyticsProvider(integration, self._default_library) + work = self._work( title=u"pi\u00F1ata", authors=u"chlo\u00E9", fiction=True, audience="audience", language="lang", @@ -52,11 +85,11 @@ def test_collect_event_with_work(self): work.target_age = NumericRange(10, 15) [lp] = work.license_pools now = datetime.datetime.utcnow() - ga.collect_event(self._db, lp, CirculationEvent.DISTRIBUTOR_CHECKIN, now) + ga.collect_event(self._default_library, lp, CirculationEvent.DISTRIBUTOR_CHECKIN, now) params = urlparse.parse_qs(ga.params) eq_(1, ga.count) - eq_("http://www.google-analytics.com/collect", ga.url) + eq_(integration.url, ga.url) eq_("faketrackingid", params['tid'][0]) eq_("event", params['t'][0]) eq_("circulation", params['ec'][0]) @@ -77,7 +110,16 @@ def test_collect_event_with_work(self): eq_("true", params['cd12'][0]) def test_collect_event_without_work(self): - ga = MockGoogleAnalyticsProvider("faketrackingid") + integration, ignore = create( + self._db, ExternalIntegration, + goal=ExternalIntegration.ANALYTICS_GOAL, + protocol="api.google_analytics_provider", + ) + integration.url = self._str + ConfigurationSetting.for_library_and_externalintegration( + self._db, GoogleAnalyticsProvider.TRACKING_ID, self._default_library, integration + ).value = "faketrackingid" + ga = MockGoogleAnalyticsProvider(integration, self._default_library) identifier = self._identifier() source = DataSource.lookup(self._db, DataSource.GUTENBERG) @@ -88,11 +130,11 @@ def test_collect_event_without_work(self): ) now = datetime.datetime.utcnow() - ga.collect_event(self._db, pool, CirculationEvent.DISTRIBUTOR_CHECKIN, now) + ga.collect_event(self._default_library, pool, CirculationEvent.DISTRIBUTOR_CHECKIN, now) params = urlparse.parse_qs(ga.params) eq_(1, ga.count) - eq_("http://www.google-analytics.com/collect", ga.url) + eq_(integration.url, ga.url) eq_("faketrackingid", params['tid'][0]) eq_("event", params['t'][0]) eq_("circulation", params['ec'][0]) @@ -111,14 +153,23 @@ def test_collect_event_without_work(self): eq_(None, params.get('cd12')) def test_collect_event_without_license_pool(self): - ga = MockGoogleAnalyticsProvider('faketrackingid') + integration, ignore = create( + self._db, ExternalIntegration, + goal=ExternalIntegration.ANALYTICS_GOAL, + protocol="api.google_analytics_provider", + ) + integration.url = self._str + ConfigurationSetting.for_library_and_externalintegration( + self._db, GoogleAnalyticsProvider.TRACKING_ID, self._default_library, integration + ).value = "faketrackingid" + ga = MockGoogleAnalyticsProvider(integration, self._default_library) now = datetime.datetime.utcnow() - ga.collect_event(self._db, None, CirculationEvent.NEW_PATRON, now) + ga.collect_event(self._default_library, None, CirculationEvent.NEW_PATRON, now) params = urlparse.parse_qs(ga.params) eq_(1, ga.count) - eq_("http://www.google-analytics.com/collect", ga.url) + eq_(integration.url, ga.url) eq_("faketrackingid", params['tid'][0]) eq_("event", params['t'][0]) eq_("circulation", params['ec'][0]) diff --git a/tests/test_overdrive.py b/tests/test_overdrive.py index 7a4520d9f..6facf8734 100644 --- a/tests/test_overdrive.py +++ b/tests/test_overdrive.py @@ -45,7 +45,7 @@ def setup(self): library = self._default_library self.collection = MockOverdriveAPI.mock_collection(self._db) self.circulation = CirculationAPI( - self._db, library, {ExternalIntegration.OVERDRIVE:MockOverdriveAPI} + self._db, library, api_map={ExternalIntegration.OVERDRIVE:MockOverdriveAPI} ) self.api = self.circulation.api_for_collection[self.collection]