Skip to content

Conversation

@jimchamp
Copy link
Collaborator

Advances #10242

Adds API for patron activity feeds.

New API is exposed via public function get_activity_feed, which takes a patron's username and returns a list of up to three recent bookshelf events. If the patron follows others, the list will be comprised of events generated by followed accounts. Otherwise, the list will contain the must recent bookshelf events triggered by patrons that have a public reading log.

Results are cached for five minutes.

Technical

A new PubSub method named is_following has been added to support this feature. is_following returns True if the patron follows any account.

ActivityFeed's get_pub_sub_feed method returns a list of recent activity from accounts that a patron follows. If the patron does not follow others, the get_trending_feed method is used to fetch the recent activity from accounts that have public reading logs. This works by first fetching the 10 most recent events, then filtering out events that were generated by accounts having private reading logs.

Testing

Screenshot

Stakeholders

Copilot AI review requested due to automatic review settings January 12, 2026 19:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new API for patron activity feeds that displays recent bookshelf events from either followed accounts or trending public reading logs. The API is exposed through a public get_activity_feed function which returns up to 3 recent events with a 5-minute cache.

Changes:

  • Added ActivityFeed class with methods to retrieve and cache activity feeds from followed users or trending public logs
  • Added PubSub.is_following method to check if a patron follows any accounts
  • Implemented caching with 5-minute TTL for both feed types with datetime deserialization support

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 10 comments.

File Description
openlibrary/plugins/upstream/mybooks.py Adds ActivityFeed class with feed retrieval logic, caching, and public API function get_activity_feed
openlibrary/core/follows.py Adds is_following method to check if a user follows anyone

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +180 to +191
@classmethod
def is_following(cls, username):
oldb = db.get_db()
query = """
SELECT EXISTS(
SELECT 1
FROM follows
WHERE subscriber=$subscriber
)
"""
result = oldb.query(query, vars={'subscriber': username})
return result and result[0].get('exists', False)
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The new is_following method in PubSub lacks test coverage. Given that the repository has comprehensive test coverage for similar database operations, tests should be added to verify: (1) Returns True when a user has at least one subscription, (2) Returns False when a user has no subscriptions, (3) Handles edge cases like non-existent usernames gracefully.

Copilot uses AI. Check for mistakes.
Comment on lines +705 to +706
Bookshelves.add_solr_works(logged_books)

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The loop condition if len(feed) > 2 will break when the feed has 3 items, but the method's documentation and the issue requirements state it should return "up to three" events. This off-by-one error means the function could return 3 items when it should stop at 3 (the condition should be >= 3 to stop once we have 3 items). However, given that the requirements ask for "up to three" and the docstring says "up to three", the current implementation is actually correct. But the use of > 2 is less clear than >= 3 for readability.

Copilot uses AI. Check for mistakes.
Comment on lines +699 to +702
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The nested function has_public_reading_log is defined but could benefit from error handling. If acct.get_user() returns None (which is checked), but if user.preferences() raises an exception or returns None, this could cause an AttributeError. Consider adding a try-except block or additional None checks to make the function more robust.

Suggested change
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False
try:
acct = OpenLibraryAccount.get_by_username(_username)
if not acct:
return False
user = acct.get_user()
if not user:
return False
prefs = user.preferences()
if not prefs:
return False
return prefs.get('public_readlog', 'no') == 'yes'
except Exception:
# If preferences are unavailable or an error occurs, treat the reading log as non-public
return False

Copilot uses AI. Check for mistakes.
Comment on lines +658 to +724
def get_activity_feed(cls, username):
"""Returns up to three of the most recent events from one of two feeds.
If the given user is not following others, the feed will contain recently
logged books. Otherwise, the feed will contain events from the patron's
`PubSub.get_feed` results.
"""
following = PubSub.is_following(username)
if following:
feed = cls.get_cached_pub_sub_feed(username)
else:
feed = cls.get_cached_trending_feed(username)

return feed or [], following

@classmethod
def get_pub_sub_feed(cls, username):
feed = PubSub.get_feed(username, limit=3)
return feed

@classmethod
def get_cached_pub_sub_feed(cls, username):
five_minutes = 5 * dateutil.MINUTE_SECS
mc = memcache_memoize(
cls.get_pub_sub_feed,
key_prefix="mybooks.pubsub.feed",
timeout=five_minutes,
prethread=caching_prethread(),
)
results = mc(username)

for r in results:
if isinstance(
r['created'], str
): # `datetime` objects are stored in cache as strings
# Update `created` to datetime, which is the type expected by `datestr` (called in card template)
r['created'] = datetime.fromisoformat(r['created'])
return results

@classmethod
def get_trending_feed(cls, username):
def has_public_reading_log(_username):
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False

logged_books = Bookshelves.get_recently_logged_books(limit=10)
Bookshelves.add_solr_works(logged_books)

feed = []
for idx, item in enumerate(logged_books):
if (
item['work']
and item['username'] != username
and has_public_reading_log(item['username'])
):
feed.append(item)
if len(feed) > 2:
break

return feed

@classmethod
def get_cached_trending_feed(cls, username):
five_minutes = 5 * dateutil.MINUTE_SECS
mc = memcache_memoize(
cls.get_trending_feed,
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The get_activity_feed method returns a tuple (feed, following), but the public API function get_activity_feed at line 724 also returns this tuple. The return type is not documented anywhere. Consider adding type hints to both methods to clarify what is being returned, especially since the second value following is a boolean that indicates whether the user is following others.

Copilot uses AI. Check for mistakes.
else:
feed = cls.get_cached_trending_feed(username)

return feed or [], following
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

In get_activity_feed, line 670 returns feed or [], following. If feed is None or falsy, it returns an empty list. However, looking at the methods get_cached_pub_sub_feed and get_cached_trending_feed, they both return the results from the cache, which could be an empty list []. The expression [] or [] evaluates to [], and None or [] also evaluates to [], so this works correctly. However, the logic is slightly confusing. Consider being more explicit, such as return feed if feed is not None else [], following or ensure the cached methods never return None.

Suggested change
return feed or [], following
return (feed if feed is not None else []), following

Copilot uses AI. Check for mistakes.
Comment on lines +655 to +724
class ActivityFeed:

@classmethod
def get_activity_feed(cls, username):
"""Returns up to three of the most recent events from one of two feeds.
If the given user is not following others, the feed will contain recently
logged books. Otherwise, the feed will contain events from the patron's
`PubSub.get_feed` results.
"""
following = PubSub.is_following(username)
if following:
feed = cls.get_cached_pub_sub_feed(username)
else:
feed = cls.get_cached_trending_feed(username)

return feed or [], following

@classmethod
def get_pub_sub_feed(cls, username):
feed = PubSub.get_feed(username, limit=3)
return feed

@classmethod
def get_cached_pub_sub_feed(cls, username):
five_minutes = 5 * dateutil.MINUTE_SECS
mc = memcache_memoize(
cls.get_pub_sub_feed,
key_prefix="mybooks.pubsub.feed",
timeout=five_minutes,
prethread=caching_prethread(),
)
results = mc(username)

for r in results:
if isinstance(
r['created'], str
): # `datetime` objects are stored in cache as strings
# Update `created` to datetime, which is the type expected by `datestr` (called in card template)
r['created'] = datetime.fromisoformat(r['created'])
return results

@classmethod
def get_trending_feed(cls, username):
def has_public_reading_log(_username):
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False

logged_books = Bookshelves.get_recently_logged_books(limit=10)
Bookshelves.add_solr_works(logged_books)

feed = []
for idx, item in enumerate(logged_books):
if (
item['work']
and item['username'] != username
and has_public_reading_log(item['username'])
):
feed.append(item)
if len(feed) > 2:
break

return feed

@classmethod
def get_cached_trending_feed(cls, username):
five_minutes = 5 * dateutil.MINUTE_SECS
mc = memcache_memoize(
cls.get_trending_feed,
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The new ActivityFeed class and the is_following method in PubSub lack test coverage. Given that the repository has comprehensive test coverage for similar core functionality (e.g., Bookshelves and BookshelvesEvents in openlibrary/tests/core/test_db.py), tests should be added to verify the behavior of these new methods. Recommended tests include: (1) Testing is_following returns True when a user follows someone and False otherwise, (2) Testing get_activity_feed returns the correct feed type based on following status, (3) Testing the caching behavior of both feed types, (4) Testing get_trending_feed correctly filters out private reading logs and the current user's activities.

Copilot uses AI. Check for mistakes.
Comment on lines +698 to +703
def has_public_reading_log(_username):
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The get_trending_feed method has a significant performance issue. For each item in logged_books (up to 10 items), it calls has_public_reading_log which performs a database lookup via OpenLibraryAccount.get_by_username. This creates an N+1 query problem where you could have up to 10 additional database queries executed in a loop. Consider optimizing this by either: (1) batch-fetching all account information upfront, or (2) implementing a more efficient SQL query that joins the necessary tables to filter accounts with public reading logs in a single query.

Suggested change
def has_public_reading_log(_username):
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False
# Cache per-username reading log visibility within this call to avoid
# repeated database lookups for the same account.
public_log_cache = {}
def has_public_reading_log(_username):
if _username in public_log_cache:
return public_log_cache[_username]
result = False
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
result = bool(
user and user.preferences().get('public_readlog', 'no') == 'yes'
)
public_log_cache[_username] = result
return result

Copilot uses AI. Check for mistakes.
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The get_trending_feed method filters items checking both item['work'] exists and that the username is not the current user. However, if no items pass the filter conditions, the loop could iterate through all 10 items without finding 3 valid ones. The function will return fewer than 3 items in this case, which is acceptable, but the enumeration with idx is not used. Remove the unused idx variable from the loop.

Copilot uses AI. Check for mistakes.
Comment on lines +683 to +719
timeout=five_minutes,
prethread=caching_prethread(),
)
results = mc(username)

for r in results:
if isinstance(
r['created'], str
): # `datetime` objects are stored in cache as strings
# Update `created` to datetime, which is the type expected by `datestr` (called in card template)
r['created'] = datetime.fromisoformat(r['created'])
return results

@classmethod
def get_trending_feed(cls, username):
def has_public_reading_log(_username):
if acct := OpenLibraryAccount.get_by_username(_username):
user = acct.get_user()
return user and user.preferences().get('public_readlog', 'no') == 'yes'
return False

logged_books = Bookshelves.get_recently_logged_books(limit=10)
Bookshelves.add_solr_works(logged_books)

feed = []
for idx, item in enumerate(logged_books):
if (
item['work']
and item['username'] != username
and has_public_reading_log(item['username'])
):
feed.append(item)
if len(feed) > 2:
break

return feed

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The datetime conversion logic modifies the cached results in-place. While this works, the same conversion code is duplicated in both get_cached_pub_sub_feed and get_cached_trending_feed. Consider extracting this datetime conversion logic into a shared helper method to follow the DRY principle and improve maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +710 to +714
item['work']
and item['username'] != username
and has_public_reading_log(item['username'])
):
feed.append(item)
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

The cache key prefix for get_cached_pub_sub_feed is "mybooks.pubsub.feed" and for get_cached_trending_feed is "mybooks.trending.feed". Both methods receive username as a parameter, which is used to generate cache keys. However, the memcache_memoize function will include the username in the cache key automatically. This means different users will have different cache entries, which is correct for get_pub_sub_feed (since each user has different followings), but for get_trending_feed, the username is only used for filtering out the user's own activities. This means all users who don't follow anyone will get the same trending feed, but it will be cached separately for each user, which is inefficient. Consider caching the trending feed globally and filtering out the current user's activities after retrieving from cache.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants