Add unique login stats to /stats page#11697
Add unique login stats to /stats page#11697mekarpeles merged 3 commits intointernetarchive:masterfrom
/stats page#11697Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds a new statistic to the /stats admin page showing the number of unique patrons who have logged in within the past 30 days. The data is fetched from the store_index table via a database query that takes several minutes to run, so the result is cached for 12 hours and loaded asynchronously on the client side to prevent blocking the page load.
Changes:
- Added database query function to count unique logins within a configurable time period
- Implemented server-side caching with 12-hour timeout to avoid expensive repeated queries
- Created asynchronous client-side loading to prevent blocking the stats page
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| openlibrary/core/admin.py | Added get_unique_logins_since() to query login counts and get_cached_unique_logins_since() wrapper with memcache memoization |
| openlibrary/plugins/openlibrary/partials.py | Added UniqueLoginCountPartial handler to expose login count data via partials API endpoint |
| openlibrary/templates/admin/index.html | Added "Unique Logins" section with loading indicator and i18n strings for displaying the count |
| openlibrary/plugins/openlibrary/js/stats/index.js | Created new module with async fetch logic to retrieve and display login counts |
| openlibrary/plugins/openlibrary/js/index.js | Added initialization code to load stats module when the monthly-login-counts element is present |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const counts = await fetchCounts() | ||
| .then((resp) => { | ||
| if (resp.status !== 200) { | ||
| throw new Error(`Failed to fetch partials. Status code: ${resp.status}`) | ||
| } | ||
| return resp.json() | ||
| }) | ||
|
|
||
| const countDiv = document.createElement('DIV') | ||
| countDiv.innerHTML = i18nStrings.uniqueLoginsCopy | ||
| const countSpan = countDiv.querySelector('.login-counts') | ||
| countSpan.textContent = counts.loginCount | ||
| loadingIndicator.replaceWith(countDiv) |
There was a problem hiding this comment.
Missing error handling for the fetch operation. If the network request fails or the promise rejects, the loading indicator will remain on the page indefinitely. Consider adding a .catch() block to handle errors and display an appropriate error message to the user.
| const counts = await fetchCounts() | |
| .then((resp) => { | |
| if (resp.status !== 200) { | |
| throw new Error(`Failed to fetch partials. Status code: ${resp.status}`) | |
| } | |
| return resp.json() | |
| }) | |
| const countDiv = document.createElement('DIV') | |
| countDiv.innerHTML = i18nStrings.uniqueLoginsCopy | |
| const countSpan = countDiv.querySelector('.login-counts') | |
| countSpan.textContent = counts.loginCount | |
| loadingIndicator.replaceWith(countDiv) | |
| try { | |
| const counts = await fetchCounts() | |
| .then((resp) => { | |
| if (resp.status !== 200) { | |
| throw new Error(`Failed to fetch partials. Status code: ${resp.status}`) | |
| } | |
| return resp.json() | |
| }) | |
| const countDiv = document.createElement('DIV') | |
| countDiv.innerHTML = i18nStrings.uniqueLoginsCopy | |
| const countSpan = countDiv.querySelector('.login-counts') | |
| countSpan.textContent = counts.loginCount | |
| loadingIndicator.replaceWith(countDiv) | |
| } catch (error) { | |
| // Ensure the loading indicator is removed and an error is shown to the user | |
| // while also logging the underlying issue for debugging purposes. | |
| // eslint-disable-next-line no-console | |
| console.error('Error fetching unique login counts:', error) | |
| const errorDiv = document.createElement('DIV') | |
| errorDiv.textContent = (i18nStrings && i18nStrings.uniqueLoginsError) || 'Unable to load unique login counts.' | |
| loadingIndicator.replaceWith(errorDiv) | |
| } |
| def get_unique_logins_since(since_days=30): | ||
| since_date = datetime.datetime.now() - datetime.timedelta(days=since_days) | ||
| date_str = since_date.strftime("%Y-%m-%d") | ||
|
|
||
| query = """ | ||
| SELECT COUNT(id) FROM store_index | ||
| WHERE type = 'account' | ||
| AND name = 'last_login' | ||
| AND value > $date | ||
| """ | ||
|
|
||
| oldb = db.get_db() | ||
| results = list(oldb.query(query, vars={"date": date_str})) | ||
|
|
||
| if not results: | ||
| return 0 | ||
| return results[0].get('count', 0) | ||
|
|
||
|
|
||
| def get_cached_unique_logins_since(since_days=30): | ||
| from openlibrary.plugins.openlibrary.home import caching_prethread | ||
|
|
||
| twelve_hours = 60 * 60 * 12 | ||
| key_prefix = 'logins_since' | ||
| mc = cache.memcache_memoize( | ||
| get_unique_logins_since, | ||
| key_prefix=key_prefix, | ||
| timeout=twelve_hours, | ||
| prethread=caching_prethread(), | ||
| ) | ||
| return mc(since_days=since_days) |
There was a problem hiding this comment.
The new get_unique_logins_since and get_cached_unique_logins_since functions lack test coverage. Given that the repository has comprehensive test infrastructure in openlibrary/tests/core/ for database functions and admin functionality, tests should be added to verify the correctness of the SQL query, caching behavior, and edge cases (e.g., no results, different date ranges).
| const countDiv = document.createElement('DIV') | ||
| countDiv.innerHTML = i18nStrings.uniqueLoginsCopy | ||
| const countSpan = countDiv.querySelector('.login-counts') | ||
| countSpan.textContent = counts.loginCount | ||
| loadingIndicator.replaceWith(countDiv) |
There was a problem hiding this comment.
Using innerHTML with user-controlled or externally fetched data could introduce XSS vulnerabilities. While the i18n string comes from the server-side template, it's safer to use textContent or createElement/appendChild for better security. Consider creating the elements programmatically rather than using innerHTML to set the entire content.
| const countDiv = document.createElement('DIV') | |
| countDiv.innerHTML = i18nStrings.uniqueLoginsCopy | |
| const countSpan = countDiv.querySelector('.login-counts') | |
| countSpan.textContent = counts.loginCount | |
| loadingIndicator.replaceWith(countDiv) | |
| const fragment = document.createRange().createContextualFragment(i18nStrings.uniqueLoginsCopy) | |
| const countSpan = fragment.querySelector('.login-counts') | |
| if (countSpan) { | |
| countSpan.textContent = counts.loginCount | |
| } | |
| loadingIndicator.replaceWith(fragment) |
There was a problem hiding this comment.
Data is fetched internally. Data is not user-provided.
| const loadingIndicator = containerElem.querySelector('.loadingIndicator') | ||
| const i18nStrings = JSON.parse(containerElem.dataset.i18n) | ||
|
|
||
| const counts = await fetchCounts() | ||
| .then((resp) => { | ||
| if (resp.status !== 200) { | ||
| throw new Error(`Failed to fetch partials. Status code: ${resp.status}`) | ||
| } | ||
| return resp.json() | ||
| }) | ||
|
|
||
| const countDiv = document.createElement('DIV') | ||
| countDiv.innerHTML = i18nStrings.uniqueLoginsCopy | ||
| const countSpan = countDiv.querySelector('.login-counts') | ||
| countSpan.textContent = counts.loginCount | ||
| loadingIndicator.replaceWith(countDiv) |
There was a problem hiding this comment.
Missing null check for loadingIndicator and countSpan elements. If the DOM structure is different than expected (e.g., due to template changes), querySelector could return null, causing a runtime error. Add checks to ensure these elements exist before calling methods on them.
| def get_cached_unique_logins_since(since_days=30): | ||
| from openlibrary.plugins.openlibrary.home import caching_prethread | ||
|
|
||
| twelve_hours = 60 * 60 * 12 | ||
| key_prefix = 'logins_since' | ||
| mc = cache.memcache_memoize( | ||
| get_unique_logins_since, | ||
| key_prefix=key_prefix, | ||
| timeout=twelve_hours, | ||
| prethread=caching_prethread(), | ||
| ) | ||
| return mc(since_days=since_days) |
There was a problem hiding this comment.
The function documentation should specify the expected return type and describe the caching behavior. Consider adding a docstring that explains the purpose, parameters, cache timeout, and return value.
| def get_unique_logins_since(since_days=30): | ||
| since_date = datetime.datetime.now() - datetime.timedelta(days=since_days) | ||
| date_str = since_date.strftime("%Y-%m-%d") | ||
|
|
||
| query = """ | ||
| SELECT COUNT(id) FROM store_index | ||
| WHERE type = 'account' | ||
| AND name = 'last_login' | ||
| AND value > $date | ||
| """ | ||
|
|
||
| oldb = db.get_db() | ||
| results = list(oldb.query(query, vars={"date": date_str})) | ||
|
|
||
| if not results: | ||
| return 0 | ||
| return results[0].get('count', 0) |
There was a problem hiding this comment.
The function documentation should specify the expected return type and describe what happens when there are no results. Consider adding a docstring similar to other functions in this module that explains the purpose, parameters, and return value.
for more information, see https://pre-commit.ci
Adds a new statistic to the
/statspage: Number of patrons who have logged in at least once during the past 30 days.This data comes from querying the
store_indextable. The query takes several minutes to run, so this value is cached for 12 hours. To prevent this from blocking the (already slow) stats page from loading, this data is fetched asynchronously once the page has loaded.Technical
Testing
Screenshot
The new stat, rendered in a local instance of Open Library.
Stakeholders