Skip to content

Commit d52436c

Browse files
authored
Merge pull request #13831 from lokesh/librarian-card
Home page: librarian call-out and a compact stats card replace the five-chart stats block
2 parents c4fe9ee + af8cf70 commit d52436c

18 files changed

Lines changed: 770 additions & 263 deletions

File tree

‎openlibrary/admin/numbers.py‎

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -105,38 +105,64 @@ def _get_cached_bot_accounts(thingdb=None) -> list[int]:
105105

106106

107107
# Public functions that are used by stats.py
108-
def admin_range__human_edits(**kargs):
109-
"""Calculates the number of edit actions performed by humans
110-
between the given `start` and `end` dates. `thingdb` is the database.
111-
"""
108+
def _count_transactions(db, start, end, where="", **vars):
109+
"Transactions between `start` and `end`, narrowed by `where` (an SQL fragment using `vars`)."
110+
sql = f"SELECT count(t.id) AS count FROM transaction t WHERE t.created >= $start and t.created < $end{where}"
111+
return db.query(sql, vars={"start": start, "end": end, **vars})[0].count
112+
113+
114+
def _range_args(fn_name, kargs):
112115
try:
113-
start = kargs["start"].strftime("%Y-%m-%d")
114-
end = kargs["end"].strftime("%Y-%m-%d %H:%M:%S")
115-
db = kargs["thingdb"]
116+
return kargs["thingdb"], kargs["start"].strftime("%Y-%m-%d"), kargs["end"].strftime("%Y-%m-%d %H:%M:%S")
116117
except KeyError as k:
117-
raise TypeError(f"{k} is a required argument for admin_range__human_edits")
118-
bot_ids = _get_cached_bot_accounts(thingdb=kargs["thingdb"])
119-
q1 = "SELECT count(t.id) AS count FROM transaction t WHERE t.created >= $start and t.created < $end AND t.author_id NOT IN $bot_ids"
120-
result = db.query(q1, vars={"bot_ids": bot_ids, "start": start, "end": end})
121-
count = result[0].count
122-
return count
118+
raise TypeError(f"{k} is a required argument for {fn_name}")
119+
120+
121+
def admin_range__human_edits(**kargs):
122+
"Transactions by non-bot accounts between `start` and `end`, signups and all. See `admin_range__catalog_edits`."
123+
db, start, end = _range_args("admin_range__human_edits", kargs)
124+
bot_ids = _get_cached_bot_accounts(thingdb=db)
125+
return _count_transactions(db, start, end, " AND t.author_id NOT IN $bot_ids", bot_ids=bot_ids)
123126

124127

125128
def admin_range__bot_edits(**kargs):
126-
"""Calculates the number of edit actions performed by bots between
127-
the `start` and `end` dates. `thingdb` is the database.
128-
"""
129-
try:
130-
start = kargs["start"].strftime("%Y-%m-%d")
131-
end = kargs["end"].strftime("%Y-%m-%d %H:%M:%S")
132-
db = kargs["thingdb"]
133-
except KeyError as k:
134-
raise TypeError(f"{k} is a required argument for admin_range__bot_edits")
135-
bot_ids = _get_cached_bot_accounts(thingdb=kargs["thingdb"])
136-
q1 = "SELECT count(t.id) AS count FROM transaction t WHERE t.created >= $start and t.created < $end AND t.author_id IN $bot_ids"
137-
result = db.query(q1, vars={"bot_ids": bot_ids, "start": start, "end": end})
138-
count = result[0].count
139-
return count
129+
"Transactions by bot accounts between `start` and `end`."
130+
db, start, end = _range_args("admin_range__bot_edits", kargs)
131+
bot_ids = _get_cached_bot_accounts(thingdb=db)
132+
return _count_transactions(db, start, end, " AND t.author_id IN $bot_ids", bot_ids=bot_ids)
133+
134+
135+
# Transactions that aren't edits to the catalog. Every signup writes a `new-account`
136+
# transaction, and at ~10k signups a day they outnumber real edits several times over.
137+
NON_EDIT_ACTIONS = (
138+
"new-account",
139+
"register",
140+
"create-stats",
141+
"edit-blocked-ips",
142+
"edit-usergroup-add-member",
143+
"edit-usergroup-delete-member",
144+
"bulk-edit-permissions",
145+
)
146+
147+
148+
def admin_range__catalog_edits(**kargs):
149+
"""Human edits that change the catalog: `admin_range__human_edits` without signups and admin
150+
housekeeping. A separate key so the daily docs written before it existed keep their meaning."""
151+
db, start, end = _range_args("admin_range__catalog_edits", kargs)
152+
bot_ids = _get_cached_bot_accounts(thingdb=db)
153+
return _count_transactions(
154+
db, start, end, " AND t.action NOT IN $non_edit_actions AND t.author_id NOT IN $bot_ids", bot_ids=bot_ids, non_edit_actions=NON_EDIT_ACTIONS
155+
)
156+
157+
158+
# Someone making or changing a list; `edit-list-seeds` is a save of what's on it.
159+
LIST_EDIT_ACTIONS = ("create-list", "edit-list-seeds")
160+
161+
162+
def admin_range__list_edits(**kargs):
163+
"Counts lists created or edited between `start` and `end`."
164+
db, start, end = _range_args("admin_range__list_edits", kargs)
165+
return _count_transactions(db, start, end, " AND t.action IN $list_edit_actions", list_edit_actions=LIST_EDIT_ACTIONS)
140166

141167

142168
def admin_range__covers(**kargs):

‎openlibrary/admin/tests/__init__.py‎

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from datetime import datetime
2+
3+
import web
4+
5+
from openlibrary.admin import numbers
6+
7+
8+
class FakeDB:
9+
"""Records the SQL `numbers` runs and answers every query with one row."""
10+
11+
def __init__(self, count=7):
12+
self.count = count
13+
self.queries = []
14+
15+
def query(self, q, vars=None):
16+
self.queries.append((q, vars))
17+
return [web.storage(count=self.count)]
18+
19+
20+
def run(fn, db):
21+
numbers.cached_bot_accounts = [11, 12]
22+
try:
23+
return fn(thingdb=db, coverdb=None, logroot=None, start=datetime(2026, 10, 15), end=datetime(2026, 10, 15, 21, 0, 0))
24+
finally:
25+
numbers.cached_bot_accounts = None
26+
27+
28+
def test_edit_counts_split_humans_from_bots():
29+
for fn, author_clause in [
30+
(numbers.admin_range__human_edits, "t.author_id NOT IN (11, 12)"),
31+
(numbers.admin_range__bot_edits, "t.author_id IN (11, 12)"),
32+
]:
33+
db = FakeDB()
34+
assert run(fn, db) == 7
35+
[(q, vars)] = db.queries
36+
sql = str(web.db.reparam(q, vars))
37+
assert author_clause in sql
38+
assert "t.action" not in sql
39+
assert "t.created >= '2026-10-15' and t.created < '2026-10-15 21:00:00'" in sql
40+
41+
42+
def test_catalog_edits_skip_account_creation_and_bots():
43+
db = FakeDB()
44+
assert run(numbers.admin_range__catalog_edits, db) == 7
45+
[(q, vars)] = db.queries
46+
sql = str(web.db.reparam(q, vars))
47+
assert "t.action NOT IN ('" in sql
48+
assert "'new-account'" in sql
49+
assert "t.author_id NOT IN (11, 12)" in sql
50+
assert "t.created >= '2026-10-15' and t.created < '2026-10-15 21:00:00'" in sql
51+
52+
53+
def test_list_edits_count_list_creation_and_seed_edits():
54+
db = FakeDB()
55+
assert run(numbers.admin_range__list_edits, db) == 7
56+
[(q, vars)] = db.queries
57+
sql = str(web.db.reparam(q, vars))
58+
assert "t.action IN ('" in sql
59+
assert "'create-list'" in sql
60+
assert "'edit-list-seeds'" in sql
61+
assert "t.created >= '2026-10-15' and t.created < '2026-10-15 21:00:00'" in sql

‎openlibrary/core/admin.py‎

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Admin functionality."""
22

33
import calendar
4+
import random
45
from datetime import UTC, date, datetime, timedelta
56

67
import requests
@@ -32,6 +33,13 @@ def __init__(self, docs, key, total_key):
3233
except KeyError, StopIteration:
3334
self.total = ""
3435

36+
def has_counts(self, ndays=28) -> bool:
37+
"""True when every doc of the last `ndays` carries this key. A key only some of them
38+
have is one the stats script started writing recently, and a series read from it would
39+
cover part of the window while claiming all of it."""
40+
docs = self.docs[-ndays:]
41+
return bool(docs) and all(self.key in doc for doc in docs)
42+
3543
def get_counts(self, ndays=28, times=False):
3644
"""Returns the stats for last n days as an array useful for
3745
plotting. i.e. an array of [x, y] tuples where y is the value
@@ -117,6 +125,11 @@ class LoanStats(Stats):
117125
loans, so the fallback (e.g. local dev) reports zero.
118126
"""
119127

128+
def has_counts(self, ndays=28) -> bool:
129+
if get_ol_env().LOCAL_DEV or not _get_loan_counts_from_graphite(ndays):
130+
return Stats.has_counts(self, ndays)
131+
return True
132+
120133
def get_counts(self, ndays=28, times=False):
121134
# Let dev.openlibrary.org show the true state of things
122135
if get_ol_env().LOCAL_DEV:
@@ -155,6 +168,9 @@ def _get_visitor_counts_from_graphite(ndays: int = 28) -> list[list[int]]:
155168

156169

157170
class VisitorStats(Stats):
171+
def has_counts(self, ndays: int = 28) -> bool:
172+
return bool(_get_visitor_counts_from_graphite(ndays))
173+
158174
def get_counts(self, ndays: int = 28, times: bool = False) -> list[tuple[int, int]]:
159175
visitors = _get_visitor_counts_from_graphite(ndays)
160176
# Flip the order, convert timestamp to msec, and convert count==None to zero
@@ -180,12 +196,14 @@ def _get_count_docs(ndays):
180196
def get_stats(ndays=30, use_mock_data=False):
181197
"""Returns the stats for the past `ndays`"""
182198
if use_mock_data:
183-
return mock_get_stats()
199+
return mock_get_stats(ndays)
184200
docs = _get_count_docs(ndays)
185201
return {
186202
"human_edits": Stats(docs, "human_edits", "human_edits"),
187203
"bot_edits": Stats(docs, "bot_edits", "bot_edits"),
204+
"catalog_edits": Stats(docs, "catalog_edits", "catalog_edits"),
188205
"lists": Stats(docs, "lists", "total_lists"),
206+
"list_edits": Stats(docs, "list_edits", "list_edits"),
189207
"visitors": VisitorStats(docs, "visitors", "visitors"),
190208
"loans": LoanStats(docs, "loans", "loans"),
191209
"members": Stats(docs, "members", "total_members"),
@@ -214,38 +232,32 @@ async def get_unique_logins_since(since_days=30):
214232
return count or 0
215233

216234

217-
def mock_get_stats():
218-
keyNames = [
219-
"human_edits",
220-
"bot_edits",
221-
"lists",
222-
"visitors",
223-
"loans",
224-
"members",
225-
"works",
226-
"editions",
227-
"ebooks",
228-
"covers",
229-
"authors",
230-
"subjects",
231-
]
232-
mockKeyValues = [[(1 + x) * y for x in range(len(keyNames))] for y in range(28)][::-1]
233-
234-
docs = [dict(zip(keyNames, mockKeyValues[x])) for x in range(len(mockKeyValues))]
235-
today = date.today()
236-
for x in range(28):
237-
docs[x]["_key"] = (today - timedelta(days=x + 1)).strftime("counts-%Y-%m-%d")
238-
return {
239-
"human_edits": Stats(docs, "human_edits", "human_edits"),
240-
"bot_edits": Stats(docs, "bot_edits", "bot_edits"),
241-
"lists": Stats(docs, "lists", "total_lists"),
242-
"visitors": Stats(docs, "visitors", "visitors"),
243-
"loans": Stats(docs, "loans", "loans"),
244-
"members": Stats(docs, "members", "total_members"),
245-
"works": Stats(docs, "works", "total_works"),
246-
"editions": Stats(docs, "editions", "total_editions"),
247-
"ebooks": Stats(docs, "ebooks", "total_ebooks"),
248-
"covers": Stats(docs, "covers", "total_covers"),
249-
"authors": Stats(docs, "authors", "total_authors"),
250-
"subjects": Stats(docs, "subjects", "total_subjects"),
235+
def mock_get_stats(ndays=30):
236+
"""Daily counts shaped like production's for local dev: a weekend dip, some noise and a
237+
gentle climb, at roughly production's scale. Seeded per key so a reload doesn't redraw."""
238+
daily_scale = {
239+
"human_edits": 12_000,
240+
"bot_edits": 20_000,
241+
"catalog_edits": 3_000,
242+
"lists": 150,
243+
"list_edits": 1_200,
244+
"visitors": 5_800_000,
245+
"loans": 10_000,
246+
"members": 7_000,
247+
"works": 3_000,
248+
"editions": 5_000,
249+
"ebooks": 100,
250+
"covers": 2_000,
251+
"authors": 1_500,
252+
"subjects": 500,
251253
}
254+
today = date.today()
255+
days = [today - timedelta(days=n) for n in range(ndays, 0, -1)]
256+
docs = [{"_key": day.strftime("counts-%Y-%m-%d")} for day in days]
257+
for key, scale in daily_scale.items():
258+
rng = random.Random(key)
259+
for i, (day, doc) in enumerate(zip(days, docs, strict=True)):
260+
weekend = 0.85 if day.weekday() >= 5 else 1.0
261+
climb = 0.85 + 0.3 * i / max(ndays - 1, 1)
262+
doc[key] = round(scale * weekend * climb * rng.uniform(0.92, 1.08))
263+
return {key: Stats(docs, key, f"total_{key}") for key in daily_scale}

0 commit comments

Comments
 (0)