Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Audiobook time tracking #1288

Merged
merged 27 commits into from
Aug 3, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b5e58f3
Time tracking models and model tests
RishiDiwanTT Jul 20, 2023
3d43dca
Time tracking route with api models added
RishiDiwanTT Jul 20, 2023
e1a00a5
Playtime summation script and tests
RishiDiwanTT Jul 21, 2023
22d74c2
Playtime reporting script added with a configurable email recipient
RishiDiwanTT Jul 21, 2023
22bcfe5
Changed API route
RishiDiwanTT Jul 21, 2023
d735181
Added time tracking links to feed entries
RishiDiwanTT Jul 24, 2023
4b0cfcd
Mypy fixes
RishiDiwanTT Jul 24, 2023
edf3756
Python 3.8 syntax fix
RishiDiwanTT Jul 24, 2023
0b51149
Alembic ordering fix
RishiDiwanTT Jul 28, 2023
0705167
Added collection and library information to the playtime entries and …
RishiDiwanTT Jul 28, 2023
23c5e51
Reporting summation groups on collection and library as well now
RishiDiwanTT Jul 28, 2023
1a1c9a0
Playtimes API validations
RishiDiwanTT Jul 28, 2023
e081f78
Modularized playtime entries
RishiDiwanTT Jul 28, 2023
4a35980
Fixed UTC date issue
RishiDiwanTT Jul 28, 2023
fd0e97a
PR updates
RishiDiwanTT Jul 31, 2023
30b0661
Added the 401 gone status for very old entries
RishiDiwanTT Jul 31, 2023
c5c942d
Playtime entries reaping cut off time
RishiDiwanTT Jul 31, 2023
87ad147
Time tracking rels only for specific collections
RishiDiwanTT Jul 31, 2023
6827875
Mypyp fixes
RishiDiwanTT Jul 31, 2023
6a93da8
Fixed 401 Gone to 410 Gone
RishiDiwanTT Aug 1, 2023
49170e5
Only a loans feed with active loans for a work will have time trackin…
RishiDiwanTT Aug 1, 2023
6e727b6
410 is now counted as a failure
RishiDiwanTT Aug 2, 2023
049696b
Fixed time tracking links
RishiDiwanTT Aug 3, 2023
3a0a689
Mypy fix
RishiDiwanTT Aug 3, 2023
c9e8a21
API spec for the route
RishiDiwanTT Aug 3, 2023
dc8d552
Switched from collection.name to colletion.id for the playtime route
RishiDiwanTT Aug 3, 2023
ba1fa75
Add missing API fields.
tdilauro Aug 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions alembic/versions/20230728_892c8e0c89f8_audiobook_playtime_tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Audiobook playtime tracking

Revision ID: 892c8e0c89f8
Revises: b3749bac3e55
Create Date: 2023-07-28 07:20:24.625484+00:00

"""
import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "892c8e0c89f8"
down_revision = "b3749bac3e55"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"playtime_entries",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("identifier_id", sa.Integer(), nullable=False),
sa.Column("collection_id", sa.Integer(), nullable=False),
sa.Column("library_id", sa.Integer(), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_seconds_played", sa.Integer(), nullable=False),
sa.Column("tracking_id", sa.String(length=64), nullable=False),
sa.Column("processed", sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(
["collection_id"],
["collections.id"],
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["identifier_id"],
["identifiers.id"],
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["library_id"], ["libraries.id"], onupdate="CASCADE", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"identifier_id", "collection_id", "library_id", "tracking_id"
),
)
op.create_table(
"playtime_summaries",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("identifier_id", sa.Integer(), nullable=True),
sa.Column("collection_id", sa.Integer(), nullable=False),
sa.Column("library_id", sa.Integer(), nullable=False),
sa.Column("identifier_str", sa.String(), nullable=False),
sa.Column("collection_name", sa.String(), nullable=False),
sa.Column("library_name", sa.String(), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_seconds_played", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["collection_id"],
["collections.id"],
onupdate="CASCADE",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["identifier_id"],
["identifiers.id"],
onupdate="CASCADE",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["library_id"], ["libraries.id"], onupdate="CASCADE", ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"identifier_str", "collection_name", "library_name", "timestamp"
),
)
op.create_index(
op.f("ix_playtime_summaries_collection_id"),
"playtime_summaries",
["collection_id"],
unique=False,
)
op.create_index(
op.f("ix_playtime_summaries_identifier_id"),
"playtime_summaries",
["identifier_id"],
unique=False,
)
op.create_index(
op.f("ix_playtime_summaries_library_id"),
"playtime_summaries",
["library_id"],
unique=False,
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
op.f("ix_playtime_summaries_library_id"), table_name="playtime_summaries"
)
op.drop_index(
op.f("ix_playtime_summaries_identifier_id"), table_name="playtime_summaries"
)
op.drop_index(
op.f("ix_playtime_summaries_collection_id"), table_name="playtime_summaries"
)
op.drop_table("playtime_summaries")
op.drop_table("playtime_entries")
# ### end Alembic commands ###
47 changes: 47 additions & 0 deletions api/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
from flask import Response, make_response, redirect
from flask_babel import lazy_gettext as _
from lxml import etree
from pydantic import ValidationError
from sqlalchemy.orm import eagerload
from sqlalchemy.orm.exc import NoResultFound

from api.authentication.access_token import AccessTokenProvider
from api.model.patron_auth import PatronAuthAccessToken
from api.model.time_tracking import PlaytimeEntriesPost, PlaytimeEntriesPostResponse
from api.opds2 import OPDS2NavigationsAnnotator, OPDS2PublicationsAnnotator
from api.saml.controller import SAMLController
from core.analytics import Analytics
Expand Down Expand Up @@ -76,6 +78,7 @@
from core.opds import AcquisitionFeed, NavigationFacets, NavigationFeed
from core.opds2 import AcquisitonFeedOPDS2
from core.opensearch import OpenSearchDocument
from core.query.playtime_entries import PlaytimeEntries
from core.user_profile import ProfileController as CoreProfileController
from core.util.authentication_for_opds import AuthenticationForOPDSDocument
from core.util.datetime_helpers import utc_now
Expand Down Expand Up @@ -189,6 +192,7 @@ class CirculationManager:
odl_notification_controller: ODLNotificationController
shared_collection_controller: SharedCollectionController
static_files: StaticFileController
playtime_entries: PlaytimeEntriesController

# Admin controllers
admin_sign_in_controller: SignInController
Expand Down Expand Up @@ -458,6 +462,7 @@ def setup_one_time_controllers(self):
self.shared_collection_controller = SharedCollectionController(self)
self.static_files = StaticFileController(self)
self.patron_auth_token = PatronAuthTokenController(self)
self.playtime_entries = PlaytimeEntriesController(self)

from api.lcp.controller import LCPController

Expand Down Expand Up @@ -2421,6 +2426,48 @@ def track_event(self, identifier_type, identifier, event_type):
return INVALID_ANALYTICS_EVENT_TYPE


class PlaytimeEntriesController(CirculationManagerController):
def track_playtimes(self, collection_name, identifier_type, identifier_idn):
library: Library = flask.request.library
tdilauro marked this conversation as resolved.
Show resolved Hide resolved
identifier = get_one(
self._db, Identifier, type=identifier_type, identifier=identifier_idn
)
collection = get_one(self._db, Collection, name=collection_name)

if not identifier:
return NOT_FOUND_ON_REMOTE.detailed(
f"The identifier {identifier_type}/{identifier_idn} was not found."
)
if not collection:
return NOT_FOUND_ON_REMOTE.detailed(
f"The collection {collection_name} was not found."
)

if collection not in library.collections:
return INVALID_INPUT.detailed("Collection was not found in the Library.")

if not identifier.licensed_through_collection(collection):
return INVALID_INPUT.detailed(
"This Identifier was not found in the Collection."
)

try:
data = PlaytimeEntriesPost(**flask.request.json)
except ValidationError as ex:
return INVALID_INPUT.detailed(ex.json())

responses, summary = PlaytimeEntries.insert_playtime_entries(
self._db, identifier, collection, library, data
)

response_data = PlaytimeEntriesPostResponse(
summary=summary, responses=responses
)
response = flask.jsonify(response_data.dict())
response.status_code = 207
return response


class ODLNotificationController(CirculationManagerController):
"""Receive notifications from an ODL distributor when the
status of a loan changes.
Expand Down
57 changes: 57 additions & 0 deletions api/model/time_tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import datetime
import logging
from typing import Any, Dict, List

from pydantic import Field, validator

from core.util.flask_util import CustomBaseModel


class PlaytimeTimeEntry(CustomBaseModel):
id: str = Field(description="An id to ensure uniqueness of the time entry")
during_minute: datetime.datetime = Field(
description="A minute boundary datetime of the format yyyy-mm-ddThh:mmZ"
)
seconds_played: int = Field(
description="How many seconds were played within this minute"
)

@validator("during_minute")
def validate_minute_datetime(cls, value: datetime.datetime):
"""Coerce the datetime to a minute boundary"""
if value.tzname() != "UTC":
logging.getLogger("TimeTracking").error(
f"An incorrect timezone was received for a playtime ({value.tzname()})."
)
raise ValueError("Timezone MUST be UTC always")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raising ValueError here causes the entire request to fail, even if only one timeEntry of many has an error. Ideally, we'd return this as a 400 response for just this entry.

I think it's unlikely for this to happen to one among many, so I think we can address this later.

value = value.replace(second=0, microsecond=0)
return value

@validator("seconds_played")
def validate_seconds_played(cls, value: int):
"""Coerce the seconds played to a max of 60 seconds"""
if value > 60:
tdilauro marked this conversation as resolved.
Show resolved Hide resolved
logging.getLogger("TimeTracking").warning(
"Greater than 60 seconds was received for a minute playtime."
)
value = 60
return value
tdilauro marked this conversation as resolved.
Show resolved Hide resolved


class PlaytimeEntriesPost(CustomBaseModel):
time_entries: List[PlaytimeTimeEntry] = Field(description="A List of time entries")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The book_id and library_id from the spec are missing here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit to fix this.



class PlaytimeEntriesPostSummary(CustomBaseModel):
total: int = 0
successes: int = 0
failures: int = 0


class PlaytimeEntriesPostResponse(CustomBaseModel):
responses: List[Dict[str, Any]] = Field(
description="Responses as part of the multi-reponse"
)
summary: PlaytimeEntriesPostSummary = Field(
description="Summary of failures and successes"
)
14 changes: 14 additions & 0 deletions api/opds.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Patron,
Session,
)
from core.model.constants import EditionConstants, LinkRelations
from core.model.formats import FormatPriorities
from core.model.integration import IntegrationConfiguration
from core.opds import AcquisitionFeed, Annotator, UnfulfillableWork
Expand Down Expand Up @@ -786,6 +787,19 @@ def annotate_work_entry(
),
)

if edition.medium == EditionConstants.AUDIO_MEDIUM:
tdilauro marked this conversation as resolved.
Show resolved Hide resolved
feed.add_link_to_entry(
entry,
rel=LinkRelations.TIME_TRACKING,
href=self.url_for(
"track_playtime_events",
identifier_type=identifier.type,
identifier=identifier.identifier,
library_short_name=self.library.short_name,
_external=True,
),
)

@classmethod
def related_books_available(cls, work, library):
""":return: bool asserting whether related books might exist for a particular Work"""
Expand Down
12 changes: 12 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,18 @@ def track_analytics_event(identifier_type, identifier, event_type):
)


@library_route(
"/playtimes/<collection_name>/<identifier_type>/<path:identifier>", methods=["POST"]
tdilauro marked this conversation as resolved.
Show resolved Hide resolved
)
@has_library
@allows_auth
@returns_problem_detail
def track_playtime_events(collection_name, identifier_type, identifier):
return app.manager.playtime_entries.track_playtimes(
collection_name, identifier_type, identifier
)


# Route that redirects to the authentication URL for a SAML provider
@library_route("/saml_authenticate")
@has_library
Expand Down
13 changes: 13 additions & 0 deletions bin/playtime_reporting
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env python
"""Sum the playtimes for audiobooks."""
import os
import sys

bin_dir = os.path.split(__file__)[0]
package_dir = os.path.join(bin_dir, "..")
sys.path.append(os.path.abspath(package_dir))

from core.jobs.playtime_entries import PlaytimeEntriesEmailReportsScript
from core.model import production_session

PlaytimeEntriesEmailReportsScript(production_session(initialize_data=False)).run()
13 changes: 13 additions & 0 deletions bin/playtime_summation
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env python
"""Sum the playtimes for audiobooks."""
import os
import sys

bin_dir = os.path.split(__file__)[0]
package_dir = os.path.join(bin_dir, "..")
sys.path.append(os.path.abspath(package_dir))

from core.jobs.playtime_entries import PlaytimeEntriesSummationScript
from core.model import production_session

PlaytimeEntriesSummationScript(production_session(initialize_data=False)).run()
3 changes: 3 additions & 0 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ class Configuration(ConfigurationConstants):
# Environment variable for SirsiDynix Auth
SIRSI_DYNIX_APP_ID = "SIMPLIFIED_SIRSI_DYNIX_APP_ID"

# Environment variable for temporary reporting email
REPORTING_EMAIL_ENVIRONMENT_VARIABLE = "SIMPLIFIED_REPORTING_EMAIL"

# ConfigurationSetting key for the base url of the app.
BASE_URL_KEY = "base_url"

Expand Down
Loading