Skip to content

Commit

Permalink
[PP-1818] implement month active user data (#2148)
Browse files Browse the repository at this point in the history
* Add patron resettable uuid.
* Add api call to reset patron activity history
* Pass patron to analytics event collection when possible.
  • Loading branch information
dbernstein authored Nov 7, 2024
1 parent 91609a2 commit 2e8316b
Show file tree
Hide file tree
Showing 16 changed files with 271 additions and 67 deletions.
49 changes: 49 additions & 0 deletions alembic/versions/20241030_272da5f400de_add_uuid_to_patron_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Add UUID to patron table
Revision ID: 272da5f400de
Revises: 3faa5bba3ddf
Create Date: 2024-10-30 17:41:28.151677+00:00
"""

import uuid

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID

# revision identifiers, used by Alembic.
revision = "272da5f400de"
down_revision = "3faa5bba3ddf"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
"patrons",
sa.Column("uuid", UUID(as_uuid=True), nullable=True, default=uuid.uuid4),
)

conn = op.get_bind()
rows = conn.execute("SELECT id from patrons").all()

for row in rows:
uid = str(uuid.uuid4())
conn.execute(
"UPDATE patrons SET uuid = ? WHERE id = ?",
(
uid,
row.id,
),
)

op.alter_column(
table_name="patrons",
column_name="uuid",
nullable=False,
)


def downgrade() -> None:
op.drop_column("patrons", "uuid")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ module = [
"palace.manager.api.controller.loan",
"palace.manager.api.controller.marc",
"palace.manager.api.controller.odl_notification",
"palace.manager.api.controller.patron_activity_history",
"palace.manager.api.discovery.*",
"palace.manager.api.enki",
"palace.manager.api.lcp.hash",
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/api/authentication/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,9 @@ def get_or_create_patron(self, _db, library_id, analytics=None):
if is_new and analytics:
# Send out an analytics event to record the fact
# that a new patron was created.
analytics.collect_event(patron.library, None, CirculationEvent.NEW_PATRON)
analytics.collect_event(
patron.library, None, CirculationEvent.NEW_PATRON, patron=patron
)

# This makes sure the Patron is brought into sync with the
# other fields of this PatronData object, regardless of
Expand Down
6 changes: 5 additions & 1 deletion src/palace/manager/api/circulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,11 @@ def _collect_event(
neighborhood = getattr(request_patron, "neighborhood", None)

self.analytics.collect_event(
library, licensepool, name, neighborhood=neighborhood
library,
licensepool,
name,
neighborhood=neighborhood,
patron=patron,
)

def _collect_checkout_event(self, patron: Patron, licensepool: LicensePool) -> None:
Expand Down
5 changes: 5 additions & 0 deletions src/palace/manager/api/circulation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
from palace.manager.api.controller.marc import MARCRecordController
from palace.manager.api.controller.odl_notification import ODLNotificationController
from palace.manager.api.controller.opds_feed import OPDSFeedController
from palace.manager.api.controller.patron_activity_history import (
PatronActivityHistoryController,
)
from palace.manager.api.controller.patron_auth_token import PatronAuthTokenController
from palace.manager.api.controller.playtime_entries import PlaytimeEntriesController
from palace.manager.api.controller.profile import ProfileController
Expand Down Expand Up @@ -110,6 +113,7 @@ class CirculationManager(LoggerMixin):
work_controller: WorkController
analytics_controller: AnalyticsController
profiles: ProfileController
patron_activity_history: PatronActivityHistoryController
patron_devices: DeviceTokensController
version: ApplicationVersionController
odl_notification_controller: ODLNotificationController
Expand Down Expand Up @@ -356,6 +360,7 @@ def setup_one_time_controllers(self):
self.analytics_controller = AnalyticsController(self)
self.profiles = ProfileController(self)
self.patron_devices = DeviceTokensController(self)
self.patron_activity_history = PatronActivityHistoryController()
self.version = ApplicationVersionController()
self.odl_notification_controller = ODLNotificationController(
self._db,
Expand Down
7 changes: 6 additions & 1 deletion src/palace/manager/api/controller/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ def track_event(self, identifier_type, identifier, event_type):
if isinstance(pools, ProblemDetail):
return pools
self.manager.analytics.collect_event(
library, pools[0], event_type, utc_now(), neighborhood=neighborhood
library,
pools[0],
event_type,
utc_now(),
neighborhood=neighborhood,
patron=patron,
)
return Response({}, 200)
else:
Expand Down
18 changes: 18 additions & 0 deletions src/palace/manager/api/controller/patron_activity_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from __future__ import annotations

import uuid

import flask
from flask import Response

from palace.manager.sqlalchemy.model.patron import Patron


class PatronActivityHistoryController:

def reset_statistics_uuid(self) -> Response:
"""Resets the patron's the statistics UUID that links the patron to past activity thus effectively erasing the
link between activity history and patron."""
patron: Patron = flask.request.patron # type: ignore [attr-defined]
patron.uuid = uuid.uuid4()
return Response("UUID reset", 200)
8 changes: 8 additions & 0 deletions src/palace/manager/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,14 @@ def patron_profile():
return app.manager.profiles.protocol()


@library_dir_route("/patrons/me/reset_statistics_uuid", methods=["PUT"])
@has_library
@allows_patron_web
@requires_auth
def reset_statistics_uuid():
return app.manager.patron_activity_history.reset_statistics_uuid()


@library_dir_route("/patrons/me/devices", methods=["GET"])
@has_library
@allows_patron_web
Expand Down
36 changes: 18 additions & 18 deletions src/palace/manager/api/s3_analytics_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from palace.manager.sqlalchemy.constants import MediaTypes
from palace.manager.sqlalchemy.model.library import Library
from palace.manager.sqlalchemy.model.licensing import LicensePool
from palace.manager.sqlalchemy.model.patron import Patron

if TYPE_CHECKING:
from palace.manager.service.storage.s3 import S3Service
Expand All @@ -25,13 +26,14 @@ def __init__(self, s3_service: S3Service | None):
@staticmethod
def _create_event_object(
library: Library,
license_pool: LicensePool,
license_pool: LicensePool | None,
event_type: str,
time: datetime.datetime,
old_value,
new_value,
old_value: int | None = None,
new_value: int | None = None,
neighborhood: str | None = None,
user_agent: str | None = None,
patron: Patron | None = None,
) -> dict:
"""Create a Python dict containing required information about the event.
Expand Down Expand Up @@ -132,26 +134,25 @@ def _create_event_object(
"language": work.language if work else None,
"open_access": license_pool.open_access if license_pool else None,
"user_agent": user_agent,
"patron_uuid": str(patron.uuid) if patron else None,
}

return event

def collect_event(
self,
library,
license_pool,
event_type,
time,
old_value=None,
new_value=None,
library: Library,
license_pool: LicensePool | None,
event_type: str,
time: datetime.datetime,
old_value: int | None = None,
new_value: int | None = None,
user_agent: str | None = None,
patron: Patron | None = None,
**kwargs,
):
"""Log the event using the appropriate for the specific provider's mechanism.
:param db: Database session
:type db: sqlalchemy.orm.session.Session
:param library: Library associated with the event
:type library: core.model.library.Library
Expand All @@ -164,9 +165,6 @@ def collect_event(
:param time: Event's timestamp
:type time: datetime.datetime
:param neighborhood: Geographic location of the event
:type neighborhood: str
:param old_value: Old value of the metric changed by the event
:type old_value: Any
Expand All @@ -175,10 +173,10 @@ def collect_event(
:param user_agent: The user_agent of the caller.
:type user_agent: str
"""
if not library and not license_pool:
raise ValueError("Either library or license_pool must be provided.")
:param patron: The patron associated with the event, where applicable
:type patron: Patron
"""

event = self._create_event_object(
library,
Expand All @@ -188,6 +186,8 @@ def collect_event(
old_value,
new_value,
user_agent=user_agent,
patron=patron,
**kwargs,
)
content = json.dumps(
event,
Expand Down
27 changes: 15 additions & 12 deletions src/palace/manager/core/local_analytics_provider.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
from datetime import datetime
from typing import Any

from sqlalchemy.orm.session import Session

from palace.manager.sqlalchemy.model.circulationevent import CirculationEvent
from palace.manager.sqlalchemy.model.library import Library
from palace.manager.sqlalchemy.model.licensing import LicensePool
from palace.manager.sqlalchemy.model.patron import Patron
from palace.manager.util.log import LoggerMixin


class LocalAnalyticsProvider(LoggerMixin):
def collect_event(
self,
library,
license_pool,
event_type,
time,
old_value=None,
new_value=None,
library: Library,
license_pool: LicensePool | None,
event_type: str,
time: datetime,
old_value: Any = None,
new_value: Any = None,
user_agent: str | None = None,
patron: Patron | None = None,
**kwargs
):
if not library and not license_pool:
raise ValueError("Either library or license_pool must be provided.")
if library:
_db = Session.object_session(library)
else:
_db = Session.object_session(license_pool)
_db = Session.object_session(library)

return CirculationEvent.log(
_db,
Expand Down
24 changes: 21 additions & 3 deletions src/palace/manager/service/analytics/analytics.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from datetime import datetime
from typing import TYPE_CHECKING, Any

import flask

from palace.manager.api.s3_analytics_provider import S3AnalyticsProvider
from palace.manager.core.local_analytics_provider import LocalAnalyticsProvider
from palace.manager.sqlalchemy.model.library import Library
from palace.manager.sqlalchemy.model.licensing import LicensePool
from palace.manager.sqlalchemy.model.patron import Patron
from palace.manager.util.datetime_helpers import utc_now
from palace.manager.util.log import LoggerMixin

Expand All @@ -31,7 +35,15 @@ def __init__(
"S3 analytics is not configured: No analytics bucket was specified."
)

def collect_event(self, library, license_pool, event_type, time=None, **kwargs): # type: ignore[no-untyped-def]
def collect_event(
self,
library: Library,
license_pool: LicensePool | None,
event_type: str,
time: datetime | None = None,
patron: Patron | None = None,
**kwargs: Any,
) -> None:
if not time:
time = utc_now()

Expand All @@ -45,7 +57,13 @@ def collect_event(self, library, license_pool, event_type, time=None, **kwargs):

for provider in self.providers:
provider.collect_event(
library, license_pool, event_type, time, user_agent=user_agent, **kwargs
library,
license_pool,
event_type,
time,
user_agent=user_agent,
patron=patron,
**kwargs,
)

def is_configured(self) -> bool:
Expand Down
7 changes: 7 additions & 0 deletions src/palace/manager/sqlalchemy/model/patron.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Unicode,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, relationship
from sqlalchemy.orm.session import Session

Expand Down Expand Up @@ -107,6 +108,12 @@ class Patron(Base, RedisKeyMixin):
# website username.
username = Column(Unicode)

# A universally unique identifier across all CMs used to track patron activity
# in a way that allows users to disassociate their patron info
# with account activity at any time. When this UUID is reset it effectively
# dissociates any patron activity history with this patron.
uuid = Column(UUID(as_uuid=True), nullable=False, default=uuid.uuid4)

# The last time this record was synced up with an external library
# system such as an ILS.
last_external_sync = Column(DateTime(timezone=True))
Expand Down
Loading

0 comments on commit 2e8316b

Please sign in to comment.