ojs 0.7.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ojs/__init__.py +1 -0
- ojs/api/__init__.py +0 -0
- ojs/api/client.py +577 -0
- ojs/api/files.py +275 -0
- ojs/api/normalize.py +485 -0
- ojs/api/schemas.py +271 -0
- ojs/api/swagger.json +10374 -0
- ojs/api/sync.py +185 -0
- ojs/cli.py +680 -0
- ojs/py.typed +0 -0
- ojs/schema.py +214 -0
- ojs/utils.py +51 -0
- ojs/website/__init__.py +0 -0
- ojs/website/articles/__init__.py +0 -0
- ojs/website/articles/normalize.py +275 -0
- ojs/website/articles/schemas.py +186 -0
- ojs/website/reviews/__init__.py +0 -0
- ojs/website/reviews/normalize.py +50 -0
- ojs/website/reviews/schemas.py +102 -0
- ojs-0.7.2.dist-info/METADATA +233 -0
- ojs-0.7.2.dist-info/RECORD +24 -0
- ojs-0.7.2.dist-info/WHEEL +4 -0
- ojs-0.7.2.dist-info/entry_points.txt +2 -0
- ojs-0.7.2.dist-info/licenses/LICENSE +22 -0
ojs/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.2"
|
ojs/api/__init__.py
ADDED
|
File without changes
|
ojs/api/client.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""OJS REST API client with pagination and publication detail fetching."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
PAGE_SIZE = 100
|
|
10
|
+
MAX_RETRIES = 3
|
|
11
|
+
RETRY_DELAY = 2
|
|
12
|
+
|
|
13
|
+
# HTTP statuses that mean "this resource isn't available to us" rather than a
|
|
14
|
+
# real failure: the key lacks permission (403) or the resource is gone (404).
|
|
15
|
+
# Callers skip the affected item and keep going instead of aborting the run.
|
|
16
|
+
SKIP_STATUSES = (403, 404)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_unchanged(sub: dict[str, Any], known: dict[int, str]) -> bool:
|
|
20
|
+
"""True when `sub`'s `dateLastActivity` matches the last-seen value in `known`.
|
|
21
|
+
|
|
22
|
+
The shared early-stop test for the per-submission detail endpoints: a match
|
|
23
|
+
means nothing changed since the previous sync, so the round-trip is skipped.
|
|
24
|
+
A submission absent from `known` (brand new) is never treated as unchanged.
|
|
25
|
+
"""
|
|
26
|
+
prior = known.get(sub["id"])
|
|
27
|
+
return prior is not None and prior == sub.get("dateLastActivity")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Response(Protocol):
|
|
31
|
+
"""The subset of `httpx.Response` the client helpers rely on."""
|
|
32
|
+
|
|
33
|
+
def raise_for_status(self) -> object: ...
|
|
34
|
+
def json(self) -> Any: ...
|
|
35
|
+
|
|
36
|
+
# Raw bytes, used by the file downloader (`files.py`) to write artifacts.
|
|
37
|
+
@property
|
|
38
|
+
def content(self) -> bytes: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _HttpClient(Protocol):
|
|
42
|
+
"""A GET-capable client, satisfied by `httpx.Client` and test doubles alike.
|
|
43
|
+
|
|
44
|
+
The helpers below only need `.get(url, params=...)`, so typing against this
|
|
45
|
+
structural contract (rather than the concrete `httpx.Client`) lets tests pass
|
|
46
|
+
lightweight fakes without subclassing the real client.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def get(self, url: str, *, params: dict[str, Any] | None = ...) -> _Response: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _http_client() -> httpx.Client:
|
|
53
|
+
"""An httpx client configured the way every fetch in this module wants it.
|
|
54
|
+
|
|
55
|
+
Centralizes the redirect-following and timeout settings so the JSON helpers
|
|
56
|
+
and the binary file downloader (``files.py``) share one definition.
|
|
57
|
+
"""
|
|
58
|
+
return httpx.Client(follow_redirects=True, timeout=30)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _redact_token(text: str) -> str:
|
|
62
|
+
"""Redact the ``apiToken`` query value so the API key never reaches logs.
|
|
63
|
+
|
|
64
|
+
The key is sent as a query parameter, so httpx echoes the full URL --
|
|
65
|
+
``?apiToken=<secret>`` and all -- in ``HTTPStatusError`` messages. Scrubbing
|
|
66
|
+
the value keeps an unexpected HTTP error from printing the key to the console
|
|
67
|
+
or a CI log.
|
|
68
|
+
"""
|
|
69
|
+
return re.sub(r"(apiToken=)[^&\s'\")]+", r"\1[REDACTED]", text)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _request_with_retry(
|
|
73
|
+
client: _HttpClient, url: str, params: dict[str, Any]
|
|
74
|
+
) -> _Response:
|
|
75
|
+
"""Make a GET request with retry on transient failures."""
|
|
76
|
+
for attempt in range(MAX_RETRIES):
|
|
77
|
+
try:
|
|
78
|
+
response = client.get(url, params=params)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
return response
|
|
81
|
+
except httpx.HTTPStatusError as e:
|
|
82
|
+
# An HTTP status error carries the request URL -- with the apiToken
|
|
83
|
+
# query value -- in its message. Re-raise a status-equivalent error
|
|
84
|
+
# with the token redacted so it can't leak to the console/logs;
|
|
85
|
+
# callers still branch on ``e.response.status_code`` for 403/404 skips.
|
|
86
|
+
raise httpx.HTTPStatusError(
|
|
87
|
+
_redact_token(str(e)), request=e.request, response=e.response
|
|
88
|
+
) from None
|
|
89
|
+
except httpx.TransportError as e:
|
|
90
|
+
# TransportError covers connect/read/pool timeouts and protocol
|
|
91
|
+
# errors -- the transient failures worth retrying. HTTP status
|
|
92
|
+
# errors (4xx/5xx) are not transport errors and propagate.
|
|
93
|
+
if attempt == MAX_RETRIES - 1:
|
|
94
|
+
raise
|
|
95
|
+
delay = RETRY_DELAY * (attempt + 1)
|
|
96
|
+
cls = e.__class__.__name__
|
|
97
|
+
print(
|
|
98
|
+
f" Retry {attempt + 1}/{MAX_RETRIES} after {cls}, "
|
|
99
|
+
f"waiting {delay}s..."
|
|
100
|
+
)
|
|
101
|
+
time.sleep(delay)
|
|
102
|
+
raise RuntimeError("unreachable")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _unwrap_items(data: Any, url: str) -> tuple[list[dict[str, Any]], int | None]:
|
|
106
|
+
"""Split an OJS list response into ``(page, total)``.
|
|
107
|
+
|
|
108
|
+
Accepts the two documented shapes -- a bare array (``total`` ``None``) and the
|
|
109
|
+
``{items, itemsMax}`` envelope (``itemsMax`` read with ``.get`` so an envelope
|
|
110
|
+
missing only the count still paginates by short-page detection). Any other 200
|
|
111
|
+
JSON (e.g. an OJS error object that slipped past ``raise_for_status``) raises a
|
|
112
|
+
``RuntimeError`` surfacing the URL and payload instead of a bare ``KeyError``.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(data, list):
|
|
115
|
+
return data, None
|
|
116
|
+
if isinstance(data, dict) and "items" in data:
|
|
117
|
+
return data["items"], data.get("itemsMax")
|
|
118
|
+
raise RuntimeError(f"Unexpected response shape from {url}: {repr(data)[:300]}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _paginate(
|
|
122
|
+
client: _HttpClient,
|
|
123
|
+
url: str,
|
|
124
|
+
params: dict[str, Any],
|
|
125
|
+
*,
|
|
126
|
+
since: str | None = None,
|
|
127
|
+
since_key: str = "dateLastActivity",
|
|
128
|
+
) -> list[dict[str, Any]]:
|
|
129
|
+
"""Fetch all pages from a paginated OJS API endpoint.
|
|
130
|
+
|
|
131
|
+
Most list endpoints return an `{items, itemsMax}` envelope. Some (the swagger
|
|
132
|
+
snapshot documents `/stats/publications` this way) return a bare array; that
|
|
133
|
+
is handled too -- a short page ends pagination, a full page advances offset.
|
|
134
|
+
|
|
135
|
+
When `since` is set the caller must also request newest-first ordering on
|
|
136
|
+
`since_key` (`orderBy=...&orderDirection=DESC`). Pagination then early-stops:
|
|
137
|
+
the first item at or below `since` ends the walk, since DESC order guarantees
|
|
138
|
+
every later record is older. Items missing `since_key` are kept (err toward
|
|
139
|
+
refetch). With `since` None the behavior is a plain full pull.
|
|
140
|
+
"""
|
|
141
|
+
all_items: list[dict[str, Any]] = []
|
|
142
|
+
offset = 0
|
|
143
|
+
|
|
144
|
+
while True:
|
|
145
|
+
params["count"] = PAGE_SIZE
|
|
146
|
+
params["offset"] = offset
|
|
147
|
+
response = _request_with_retry(client, url, params)
|
|
148
|
+
data = response.json()
|
|
149
|
+
|
|
150
|
+
page, total = _unwrap_items(data, url)
|
|
151
|
+
|
|
152
|
+
reached_since = False
|
|
153
|
+
if since is not None:
|
|
154
|
+
kept = []
|
|
155
|
+
for item in page:
|
|
156
|
+
ts = item.get(since_key)
|
|
157
|
+
if ts is not None and ts <= since:
|
|
158
|
+
reached_since = True
|
|
159
|
+
break
|
|
160
|
+
kept.append(item)
|
|
161
|
+
all_items.extend(kept)
|
|
162
|
+
else:
|
|
163
|
+
all_items.extend(page)
|
|
164
|
+
|
|
165
|
+
if total is None:
|
|
166
|
+
print(f" Fetched {len(all_items)}")
|
|
167
|
+
else:
|
|
168
|
+
print(f" Fetched {len(all_items)}/{total}")
|
|
169
|
+
|
|
170
|
+
if reached_since:
|
|
171
|
+
break
|
|
172
|
+
if total is None:
|
|
173
|
+
if len(page) < PAGE_SIZE:
|
|
174
|
+
break
|
|
175
|
+
elif len(all_items) >= total or not page:
|
|
176
|
+
break
|
|
177
|
+
offset += PAGE_SIZE
|
|
178
|
+
|
|
179
|
+
return all_items
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _submission_list_params(api_key: str, since: str | None) -> dict[str, Any]:
|
|
183
|
+
"""Query params for a submission list call, ordered for early-stop when incremental.
|
|
184
|
+
|
|
185
|
+
With `since` set, page newest-first by `dateLastActivity` so `_paginate` can
|
|
186
|
+
stop at the watermark. Both `/submissions` and `/_submissions` accept this
|
|
187
|
+
ordering.
|
|
188
|
+
"""
|
|
189
|
+
params = {"apiToken": api_key}
|
|
190
|
+
if since is not None:
|
|
191
|
+
params["orderBy"] = "dateLastActivity"
|
|
192
|
+
params["orderDirection"] = "DESC"
|
|
193
|
+
return params
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def fetch_submissions(
|
|
197
|
+
base_url: str, api_key: str, *, since: str | None = None
|
|
198
|
+
) -> list[dict[str, Any]]:
|
|
199
|
+
"""Fetch submissions from /submissions.
|
|
200
|
+
|
|
201
|
+
With `since` set, returns only submissions whose `dateLastActivity` is newer
|
|
202
|
+
than the watermark (incremental delta); with `since` None, a full pull.
|
|
203
|
+
"""
|
|
204
|
+
print("Fetching submissions...")
|
|
205
|
+
with _http_client() as client:
|
|
206
|
+
url = f"{base_url}/api/v1/submissions"
|
|
207
|
+
return _paginate(
|
|
208
|
+
client, url, _submission_list_params(api_key, since), since=since
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def fetch_submissions_extended(
|
|
213
|
+
base_url: str, api_key: str, *, since: str | None = None
|
|
214
|
+
) -> list[dict[str, Any]]:
|
|
215
|
+
"""Fetch submissions from /_submissions (includes review data).
|
|
216
|
+
|
|
217
|
+
Honors `since` for an incremental delta, the same way as `fetch_submissions`.
|
|
218
|
+
"""
|
|
219
|
+
print("Fetching extended submissions (with review data)...")
|
|
220
|
+
with _http_client() as client:
|
|
221
|
+
url = f"{base_url}/api/v1/_submissions"
|
|
222
|
+
return _paginate(
|
|
223
|
+
client, url, _submission_list_params(api_key, since), since=since
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fetch_publication(
|
|
228
|
+
client: httpx.Client,
|
|
229
|
+
base_url: str,
|
|
230
|
+
api_key: str,
|
|
231
|
+
submission_id: int,
|
|
232
|
+
publication_id: int,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Fetch a publication's detail on an open client, tagged with submission id."""
|
|
235
|
+
url = f"{base_url}/api/v1/submissions/{submission_id}/publications/{publication_id}"
|
|
236
|
+
response = _request_with_retry(client, url, {"apiToken": api_key})
|
|
237
|
+
pub = response.json()
|
|
238
|
+
pub["_submission_id"] = submission_id
|
|
239
|
+
return pub
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def fetch_publication(
|
|
243
|
+
base_url: str, api_key: str, submission_id: int, publication_id: int
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Fetch a single submission's full publication detail (one-off request)."""
|
|
246
|
+
with _http_client() as client:
|
|
247
|
+
return _fetch_publication(
|
|
248
|
+
client, base_url, api_key, submission_id, publication_id
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def fetch_all_publications(
|
|
253
|
+
base_url: str,
|
|
254
|
+
api_key: str,
|
|
255
|
+
submissions: list[dict[str, Any]],
|
|
256
|
+
*,
|
|
257
|
+
known: dict[int, str] | None = None,
|
|
258
|
+
) -> list[dict[str, Any]]:
|
|
259
|
+
"""Fetch full publication details for the given submissions.
|
|
260
|
+
|
|
261
|
+
`known` maps submission_id -> last-seen `dateLastActivity`. When a
|
|
262
|
+
submission's current `dateLastActivity` matches its `known` value, the detail
|
|
263
|
+
GET is skipped as unchanged -- the biggest incremental saving, since this
|
|
264
|
+
endpoint costs one HTTP round-trip per submission. Brand-new submissions
|
|
265
|
+
(absent from `known`) are always fetched.
|
|
266
|
+
"""
|
|
267
|
+
known = known or {}
|
|
268
|
+
print(f"Fetching full publication details for {len(submissions)} submissions...")
|
|
269
|
+
publications = []
|
|
270
|
+
skipped = 0
|
|
271
|
+
|
|
272
|
+
with _http_client() as client:
|
|
273
|
+
for i, sub in enumerate(submissions):
|
|
274
|
+
pubs = sub.get("publications", [])
|
|
275
|
+
if not pubs:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
if _is_unchanged(sub, known):
|
|
279
|
+
skipped += 1
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
pub = _fetch_publication(
|
|
283
|
+
client, base_url, api_key, sub["id"], pubs[0]["id"]
|
|
284
|
+
)
|
|
285
|
+
publications.append(pub)
|
|
286
|
+
|
|
287
|
+
if (i + 1) % 50 == 0:
|
|
288
|
+
print(f" Fetched {i + 1}/{len(submissions)} publications")
|
|
289
|
+
|
|
290
|
+
if skipped:
|
|
291
|
+
print(f" Skipped {skipped} unchanged publications")
|
|
292
|
+
print(f" Fetched {len(publications)}/{len(submissions)} publications")
|
|
293
|
+
return publications
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _submission_files_url(base_url: str, submission_id: int) -> str:
|
|
297
|
+
return f"{base_url}/api/v1/submissions/{submission_id}/files"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _file_query_params(
|
|
301
|
+
api_key: str,
|
|
302
|
+
*,
|
|
303
|
+
file_stages: list[int] | None,
|
|
304
|
+
review_round_ids: list[int] | None,
|
|
305
|
+
) -> dict[str, Any]:
|
|
306
|
+
"""Query params for the files list endpoint, dropping unset filters.
|
|
307
|
+
|
|
308
|
+
`fileStages` and `reviewRoundIds` are `style=form, explode=false` array
|
|
309
|
+
params in the OJS spec, i.e. comma-separated single values (`fileStages=4,15`)
|
|
310
|
+
-- the same encoding `_stats_params` uses for `submissionIds`.
|
|
311
|
+
"""
|
|
312
|
+
params: dict[str, Any] = {"apiToken": api_key}
|
|
313
|
+
if file_stages:
|
|
314
|
+
params["fileStages"] = ",".join(str(s) for s in file_stages)
|
|
315
|
+
if review_round_ids:
|
|
316
|
+
params["reviewRoundIds"] = ",".join(str(r) for r in review_round_ids)
|
|
317
|
+
return params
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _fetch_submission_files(
|
|
321
|
+
client: _HttpClient,
|
|
322
|
+
base_url: str,
|
|
323
|
+
api_key: str,
|
|
324
|
+
submission_id: int,
|
|
325
|
+
*,
|
|
326
|
+
file_stages: list[int] | None,
|
|
327
|
+
review_round_ids: list[int] | None,
|
|
328
|
+
) -> list[dict[str, Any]]:
|
|
329
|
+
"""Fetch one submission's files on an open client, tagging each with its id.
|
|
330
|
+
|
|
331
|
+
The endpoint returns the standard `{items, itemsMax}` envelope (older swagger
|
|
332
|
+
snapshots document a bare array; both are accepted). It is scoped to a single
|
|
333
|
+
submission, which is never expected to carry a full page of files, so this
|
|
334
|
+
does not paginate.
|
|
335
|
+
"""
|
|
336
|
+
url = _submission_files_url(base_url, submission_id)
|
|
337
|
+
params = _file_query_params(
|
|
338
|
+
api_key, file_stages=file_stages, review_round_ids=review_round_ids
|
|
339
|
+
)
|
|
340
|
+
payload = _request_with_retry(client, url, params).json()
|
|
341
|
+
files, _ = _unwrap_items(payload, url)
|
|
342
|
+
for f in files:
|
|
343
|
+
f["_submission_id"] = submission_id
|
|
344
|
+
return files
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def fetch_submission_files(
|
|
348
|
+
base_url: str,
|
|
349
|
+
api_key: str,
|
|
350
|
+
submission_id: int,
|
|
351
|
+
*,
|
|
352
|
+
file_stages: list[int] | None = None,
|
|
353
|
+
review_round_ids: list[int] | None = None,
|
|
354
|
+
) -> list[dict[str, Any]]:
|
|
355
|
+
"""Fetch the file metadata for a single submission (one-off request)."""
|
|
356
|
+
with _http_client() as client:
|
|
357
|
+
return _fetch_submission_files(
|
|
358
|
+
client,
|
|
359
|
+
base_url,
|
|
360
|
+
api_key,
|
|
361
|
+
submission_id,
|
|
362
|
+
file_stages=file_stages,
|
|
363
|
+
review_round_ids=review_round_ids,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def fetch_all_submission_files(
|
|
368
|
+
base_url: str,
|
|
369
|
+
api_key: str,
|
|
370
|
+
submissions: list[dict[str, Any]],
|
|
371
|
+
*,
|
|
372
|
+
known: dict[int, str] | None = None,
|
|
373
|
+
file_stages: list[int] | None = None,
|
|
374
|
+
review_round_ids: list[int] | None = None,
|
|
375
|
+
) -> list[dict[str, Any]]:
|
|
376
|
+
"""Fetch file metadata for the given submissions, one request each.
|
|
377
|
+
|
|
378
|
+
`known` maps submission_id -> last-seen `dateLastActivity`. A submission
|
|
379
|
+
whose current `dateLastActivity` matches its `known` value is skipped as
|
|
380
|
+
unchanged -- the same incremental saving used by `fetch_all_publications`,
|
|
381
|
+
since this endpoint costs one HTTP round-trip per submission. New
|
|
382
|
+
submissions (absent from `known`) are always fetched.
|
|
383
|
+
"""
|
|
384
|
+
known = known or {}
|
|
385
|
+
print(f"Fetching submission files for {len(submissions)} submissions...")
|
|
386
|
+
files: list[dict[str, Any]] = []
|
|
387
|
+
skipped = 0
|
|
388
|
+
|
|
389
|
+
with _http_client() as client:
|
|
390
|
+
for i, sub in enumerate(submissions):
|
|
391
|
+
if _is_unchanged(sub, known):
|
|
392
|
+
skipped += 1
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
files.extend(
|
|
396
|
+
_fetch_submission_files(
|
|
397
|
+
client,
|
|
398
|
+
base_url,
|
|
399
|
+
api_key,
|
|
400
|
+
sub["id"],
|
|
401
|
+
file_stages=file_stages,
|
|
402
|
+
review_round_ids=review_round_ids,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if (i + 1) % 50 == 0:
|
|
407
|
+
print(f" Fetched files for {i + 1}/{len(submissions)} submissions")
|
|
408
|
+
|
|
409
|
+
if skipped:
|
|
410
|
+
print(f" Skipped {skipped} unchanged submissions")
|
|
411
|
+
print(f" Fetched {len(files)} file records")
|
|
412
|
+
return files
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def fetch_users(base_url: str, api_key: str) -> list[dict[str, Any]]:
|
|
416
|
+
"""Fetch all users from /users endpoint."""
|
|
417
|
+
print("Fetching users...")
|
|
418
|
+
with _http_client() as client:
|
|
419
|
+
url = f"{base_url}/api/v1/users"
|
|
420
|
+
return _paginate(client, url, {"apiToken": api_key})
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _stats_params(
|
|
424
|
+
api_key: str,
|
|
425
|
+
*,
|
|
426
|
+
date_start: str | None = None,
|
|
427
|
+
date_end: str | None = None,
|
|
428
|
+
submission_ids: list[int] | None = None,
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
"""Build a query-param dict for the /stats endpoints, dropping unset filters."""
|
|
431
|
+
params = {"apiToken": api_key}
|
|
432
|
+
if date_start:
|
|
433
|
+
params["dateStart"] = date_start
|
|
434
|
+
if date_end:
|
|
435
|
+
params["dateEnd"] = date_end
|
|
436
|
+
if submission_ids:
|
|
437
|
+
# OJS expects a comma-separated list (style=form, explode=false).
|
|
438
|
+
params["submissionIds"] = ",".join(str(s) for s in submission_ids)
|
|
439
|
+
return params
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def fetch_publication_stats(
|
|
443
|
+
base_url: str,
|
|
444
|
+
api_key: str,
|
|
445
|
+
*,
|
|
446
|
+
date_start: str | None = None,
|
|
447
|
+
date_end: str | None = None,
|
|
448
|
+
submission_ids: list[int] | None = None,
|
|
449
|
+
) -> list[dict[str, Any]]:
|
|
450
|
+
"""Fetch per-publication view totals from /stats/publications.
|
|
451
|
+
|
|
452
|
+
Returns one record per publication with abstract, all-galley, PDF, HTML, and
|
|
453
|
+
other view counts. `_paginate` tolerates both the `{items, itemsMax}`
|
|
454
|
+
envelope and a bare-array response from this endpoint.
|
|
455
|
+
"""
|
|
456
|
+
print("Fetching publication view stats...")
|
|
457
|
+
params = _stats_params(
|
|
458
|
+
api_key,
|
|
459
|
+
date_start=date_start,
|
|
460
|
+
date_end=date_end,
|
|
461
|
+
submission_ids=submission_ids,
|
|
462
|
+
)
|
|
463
|
+
with _http_client() as client:
|
|
464
|
+
url = f"{base_url}/api/v1/stats/publications"
|
|
465
|
+
return _paginate(client, url, params)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _request_timeline(
|
|
469
|
+
client: httpx.Client,
|
|
470
|
+
base_url: str,
|
|
471
|
+
api_key: str,
|
|
472
|
+
submission_id: int,
|
|
473
|
+
kind: str,
|
|
474
|
+
*,
|
|
475
|
+
interval: str,
|
|
476
|
+
date_start: str | None,
|
|
477
|
+
date_end: str | None,
|
|
478
|
+
) -> list[dict[str, Any]]:
|
|
479
|
+
"""Fetch one publication's abstract- or galley-view timeline.
|
|
480
|
+
|
|
481
|
+
Returns a flat list of `{date, label, value}` points for the requested
|
|
482
|
+
submission. These per-publication endpoints are not paginated.
|
|
483
|
+
"""
|
|
484
|
+
params = _stats_params(api_key, date_start=date_start, date_end=date_end)
|
|
485
|
+
params["timelineInterval"] = interval
|
|
486
|
+
url = f"{base_url}/api/v1/stats/publications/{submission_id}/{kind}"
|
|
487
|
+
response = _request_with_retry(client, url, params)
|
|
488
|
+
return response.json()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def fetch_view_timelines(
|
|
492
|
+
base_url: str,
|
|
493
|
+
api_key: str,
|
|
494
|
+
submission_ids: list[int],
|
|
495
|
+
*,
|
|
496
|
+
interval: str = "day",
|
|
497
|
+
date_start: str | None = None,
|
|
498
|
+
date_end: str | None = None,
|
|
499
|
+
) -> list[dict[str, Any]]:
|
|
500
|
+
"""Fetch per-submission abstract and galley view timelines.
|
|
501
|
+
|
|
502
|
+
Makes one request per submission per view type and returns a flat list of
|
|
503
|
+
timeline points, each tagged with `_submission_id`, `kind`, and `interval`
|
|
504
|
+
(the granularity it was fetched at), ready for normalization into a long
|
|
505
|
+
table. Defaults to daily resolution, the finest granularity the OJS stats API
|
|
506
|
+
exposes.
|
|
507
|
+
"""
|
|
508
|
+
total = len(submission_ids)
|
|
509
|
+
print(f"Fetching view timelines for {total} submissions ({interval})...")
|
|
510
|
+
points = []
|
|
511
|
+
|
|
512
|
+
with _http_client() as client:
|
|
513
|
+
for i, submission_id in enumerate(submission_ids):
|
|
514
|
+
for kind in ("abstract", "galley"):
|
|
515
|
+
series = _request_timeline(
|
|
516
|
+
client,
|
|
517
|
+
base_url,
|
|
518
|
+
api_key,
|
|
519
|
+
submission_id,
|
|
520
|
+
kind,
|
|
521
|
+
interval=interval,
|
|
522
|
+
date_start=date_start,
|
|
523
|
+
date_end=date_end,
|
|
524
|
+
)
|
|
525
|
+
for point in series:
|
|
526
|
+
points.append(
|
|
527
|
+
{
|
|
528
|
+
**point,
|
|
529
|
+
"_submission_id": submission_id,
|
|
530
|
+
"kind": kind,
|
|
531
|
+
"interval": interval,
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if (i + 1) % 50 == 0:
|
|
536
|
+
print(f" Fetched {i + 1}/{total} submission timelines")
|
|
537
|
+
|
|
538
|
+
# Final tally, unless the in-loop print just emitted it.
|
|
539
|
+
if total and total % 50 != 0:
|
|
540
|
+
print(f" Fetched {total}/{total} submission timelines")
|
|
541
|
+
return points
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def fetch_view_timeline_totals(
|
|
545
|
+
base_url: str,
|
|
546
|
+
api_key: str,
|
|
547
|
+
*,
|
|
548
|
+
interval: str = "day",
|
|
549
|
+
date_start: str | None = None,
|
|
550
|
+
date_end: str | None = None,
|
|
551
|
+
submission_ids: list[int] | None = None,
|
|
552
|
+
) -> list[dict[str, Any]]:
|
|
553
|
+
"""Fetch journal-wide abstract and galley view timelines.
|
|
554
|
+
|
|
555
|
+
Hits the aggregate `/stats/publications/abstract` and
|
|
556
|
+
`/stats/publications/galley` endpoints (no submission id) -- the same data
|
|
557
|
+
the OJS statistics page graphs by month over the full span. Returns a flat
|
|
558
|
+
list of timeline points, each tagged with `kind` (`abstract` or `galley`)
|
|
559
|
+
and `interval` (the granularity), ready to normalize into a journal-wide
|
|
560
|
+
totals table.
|
|
561
|
+
"""
|
|
562
|
+
print(f"Fetching journal-wide view timeline totals ({interval})...")
|
|
563
|
+
params = _stats_params(
|
|
564
|
+
api_key,
|
|
565
|
+
date_start=date_start,
|
|
566
|
+
date_end=date_end,
|
|
567
|
+
submission_ids=submission_ids,
|
|
568
|
+
)
|
|
569
|
+
params["timelineInterval"] = interval
|
|
570
|
+
points = []
|
|
571
|
+
with _http_client() as client:
|
|
572
|
+
for kind in ("abstract", "galley"):
|
|
573
|
+
url = f"{base_url}/api/v1/stats/publications/{kind}"
|
|
574
|
+
series = _request_with_retry(client, url, params).json()
|
|
575
|
+
for point in series:
|
|
576
|
+
points.append({**point, "kind": kind, "interval": interval})
|
|
577
|
+
return points
|