Skip to content

Commit d08c8f5

Browse files
committed
stats: add order stats endpoint and extend orders index
* the endpoint returns a histogram for stats where requested metrics are grouped by and aggregated * extend the orders index with a new stats object * the stats object contains order_processing_time and document_request_waiting_time
1 parent dc20345 commit d08c8f5

File tree

14 files changed

+656
-83
lines changed

14 files changed

+656
-83
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# invenio-app-ils is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Order indexer APIs."""
9+
10+
from datetime import datetime
11+
12+
from invenio_search import current_search_client
13+
14+
from invenio_app_ils.acquisition.api import ORDER_PID_TYPE
15+
from invenio_app_ils.proxies import current_app_ils
16+
17+
18+
def index_stats_fields_for_order(order_dict):
19+
"""Indexer hook to modify the order record dict before indexing."""
20+
21+
# This is done through the hook and not through an indexer class,
22+
# as we need access to the `_created` field
23+
24+
# Only calculate stats if order is received
25+
if not order_dict.get("received_date"):
26+
return
27+
28+
stats = {}
29+
30+
received_date = datetime.fromisoformat(order_dict["received_date"]).date()
31+
creation_date = datetime.fromisoformat(order_dict["_created"]).date()
32+
33+
# Calculate ordering_time
34+
ordering_time = (received_date - creation_date).days
35+
stats["order_processing_time"] = ordering_time if ordering_time >= 0 else None
36+
37+
# Find related document request if any
38+
order_pid = order_dict.get("pid")
39+
if order_pid:
40+
# Search for document requests that reference this order
41+
doc_req_search_cls = current_app_ils.document_request_search_cls
42+
search_body = {
43+
"query": {
44+
"bool": {
45+
"must": [
46+
{"term": {"physical_item_provider.pid": order_pid}},
47+
{"term": {"physical_item_provider.pid_type": ORDER_PID_TYPE}},
48+
],
49+
}
50+
},
51+
"size": 1,
52+
}
53+
54+
search_result = current_search_client.search(
55+
index=doc_req_search_cls.Meta.index, body=search_body
56+
)
57+
58+
hits = search_result["hits"]["hits"]
59+
if len(hits) > 0:
60+
doc_request = hits[0]["_source"]
61+
doc_req_creation_date = datetime.fromisoformat(
62+
doc_request["_created"]
63+
).date()
64+
65+
order_dict["doc_request"] = {}
66+
67+
# Calculate document_request_waiting_time: received_date - document request creation date
68+
waiting_time = (received_date - doc_req_creation_date).days
69+
stats["document_request_waiting_time"] = (
70+
waiting_time if waiting_time >= 0 else None
71+
)
72+
73+
order_dict["stats"] = stats

invenio_app_ils/acquisition/mappings/os-v2/acq_orders/order-v1.0.0.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,11 @@
358358
},
359359
"provider_pid": {
360360
"type": "keyword"
361+
},
362+
"stats": {
363+
"type": "object",
364+
"dynamic": true,
365+
"enabled": true
361366
}
362367
}
363368
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# invenio-app-ils is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Invenio App ILS acquisition stats module."""
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# invenio-app-ils is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Invenio App ILS acquisition stats views."""
9+
10+
from flask import Blueprint, request
11+
from invenio_records_rest.query import default_search_factory
12+
from invenio_rest import ContentNegotiatedMethodView
13+
from marshmallow.exceptions import ValidationError
14+
15+
from invenio_app_ils.acquisition.api import ORDER_PID_TYPE
16+
from invenio_app_ils.acquisition.proxies import current_ils_acq
17+
from invenio_app_ils.errors import InvalidParameterError
18+
from invenio_app_ils.permissions import need_permissions
19+
from invenio_app_ils.stats.histogram import (
20+
HistogramParamsSchema,
21+
create_histogram_view,
22+
get_record_statistics,
23+
)
24+
25+
26+
def create_acquisition_stats_blueprint(app):
27+
"""Add statistics views to the blueprint."""
28+
blueprint = Blueprint("invenio_app_ils_acquisition_stats", __name__, url_prefix="")
29+
30+
create_histogram_view(
31+
blueprint, app, ORDER_PID_TYPE, OrderHistogramResource, "/acquisition"
32+
)
33+
34+
return blueprint
35+
36+
37+
class OrderHistogramResource(ContentNegotiatedMethodView):
38+
"""Order stats resource."""
39+
40+
view_name = "order_histogram"
41+
42+
def __init__(self, serializers, ctx, *args, **kwargs):
43+
"""Constructor."""
44+
super().__init__(serializers, *args, **kwargs)
45+
for key, value in ctx.items():
46+
setattr(self, key, value)
47+
48+
@need_permissions("stats-orders")
49+
def get(self, **kwargs):
50+
"""Get order statistics."""
51+
52+
order_date_fields = [
53+
"order_date",
54+
"expected_delivery_date",
55+
"received_date",
56+
"_created",
57+
"_updated",
58+
]
59+
60+
schema = HistogramParamsSchema(order_date_fields)
61+
try:
62+
parsed_args = schema.load(request.args.to_dict())
63+
except ValidationError as e:
64+
raise InvalidParameterError(description=e.messages) from e
65+
66+
# Construct search to allow for filtering with the q parameter
67+
search_cls = current_ils_acq.order_search_cls
68+
search = search_cls()
69+
search, _ = default_search_factory(self, search)
70+
71+
aggregation_buckets = get_record_statistics(
72+
order_date_fields,
73+
search,
74+
parsed_args["group_by"],
75+
parsed_args["metrics"],
76+
)
77+
78+
response = {
79+
"buckets": aggregation_buckets,
80+
}
81+
82+
return self.make_response(response, 200)

invenio_app_ils/document_requests/indexer.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from flask import current_app
1414
from invenio_indexer.api import RecordIndexer
1515

16+
from invenio_app_ils.acquisition.api import ORDER_PID_TYPE
17+
from invenio_app_ils.acquisition.proxies import current_ils_acq
1618
from invenio_app_ils.documents.api import DOCUMENT_PID_TYPE
1719
from invenio_app_ils.indexer import ReferencedRecordsIndexer
1820
from invenio_app_ils.proxies import current_app_ils
@@ -26,14 +28,25 @@ def index_referenced_records(docreq):
2628
indexer = ReferencedRecordsIndexer()
2729
indexed = dict(pid_type=DOCUMENT_REQUEST_PID_TYPE, record=docreq)
2830

31+
referenced = []
32+
2933
# fetch and index the document
3034
document_pid = docreq.get("document_pid")
31-
referenced = []
3235
if document_pid:
3336
document_cls = current_app_ils.document_record_cls
3437
document = document_cls.get_record_by_pid(document_pid)
3538
referenced.append(dict(pid_type=DOCUMENT_PID_TYPE, record=document))
3639

40+
# fetch and index the related order (physical_item_provider)
41+
physical_item_provider = docreq.get("physical_item_provider")
42+
if physical_item_provider:
43+
provider_pid = physical_item_provider.get("pid")
44+
provider_pid_type = physical_item_provider.get("pid_type")
45+
if provider_pid and provider_pid_type == ORDER_PID_TYPE:
46+
order_cls = current_ils_acq.order_record_cls
47+
order = order_cls.get_record_by_pid(provider_pid)
48+
referenced.append(dict(pid_type=ORDER_PID_TYPE, record=order))
49+
3750
indexer.index(indexed, referenced)
3851

3952

invenio_app_ils/ext.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121

2222
from .circulation import config as circulation_config
23+
from .acquisition.indexer import index_stats_fields_for_order
2324
from .circulation.indexer import (
2425
index_extra_fields_for_loan,
2526
index_stats_fields_for_loan,
@@ -224,6 +225,7 @@ def __init__(self, app=None):
224225
self.init_app(app)
225226
self.init_metadata_extensions(app)
226227
self.init_loan_indexer_hook(app)
228+
self.init_order_indexer_hook(app)
227229

228230
def init_app(self, app):
229231
"""Flask application initialization."""
@@ -278,6 +280,15 @@ def init_loan_indexer_hook(self, app):
278280
index="{0}s-{0}-v1.0.0".format("loan"),
279281
)
280282

283+
def init_order_indexer_hook(self, app):
284+
"""Custom order indexer hook init."""
285+
before_record_index.dynamic_connect(
286+
before_order_index_hook,
287+
sender=app,
288+
weak=False,
289+
index="acq_orders-order-v1.0.0",
290+
)
291+
281292
def update_config_records_rest(self, app):
282293
"""Merge overridden circ records rest into global records rest."""
283294
for k in dir(circulation_config):
@@ -331,3 +342,15 @@ def before_loan_index_hook(sender, json=None, record=None, index=None, **kwargs)
331342
"""
332343
index_extra_fields_for_loan(json)
333344
index_stats_fields_for_loan(json)
345+
346+
347+
def before_order_index_hook(sender, json=None, record=None, index=None, **kwargs):
348+
"""Hook to transform order record before ES indexing.
349+
350+
:param sender: The entity sending the signal.
351+
:param json: The dumped Record dict which will be indexed.
352+
:param record: The corresponding Record object.
353+
:param index: The index in which the json will be indexed.
354+
:param kwargs: Any other parameters.
355+
"""
356+
index_stats_fields_for_order(json)

invenio_app_ils/permissions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def __init__(self, record):
206206
_is_backoffice_read_permission = [
207207
"stats-most-loaned",
208208
"stats-loans",
209+
"stats-orders",
209210
"get-notifications-sent-to-patron",
210211
]
211212
_is_patron_owner_permission = [

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ invenio_base.api_apps =
114114
invenio_base.api_blueprints =
115115
ils_circulation = invenio_app_ils.circulation.views:create_circulation_blueprint
116116
ils_circulation_stats = invenio_app_ils.circulation.stats.views:create_circulation_stats_blueprint
117+
ils_acquisition_stats = invenio_app_ils.acquisition.stats.views:create_acquisition_stats_blueprint
117118
ils_ill = invenio_app_ils.ill.views:create_ill_blueprint
118119
ils_relations = invenio_app_ils.records_relations.views:create_relations_blueprint
119120
ils_document_request = invenio_app_ils.document_requests.views:create_document_request_action_blueprint

tests/api/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,23 @@ def testdata_loan_histogram(db, testdata):
201201
return testdata
202202

203203

204+
@pytest.fixture()
205+
def testdata_order_histogram(db, testdata):
206+
"""Create, index and return test data for orders histogram."""
207+
orders_histogram = load_json_from_datadir("acq_orders_histogram.json")
208+
recs = _create_records(db, orders_histogram, Order, ORDER_PID_TYPE)
209+
210+
ri = RecordIndexer()
211+
for rec in recs:
212+
ri.index(rec)
213+
214+
current_search.flush_and_refresh(index="acq_orders")
215+
216+
testdata["orders_histogram"] = orders_histogram
217+
218+
return testdata
219+
220+
204221
@pytest.fixture()
205222
def item_record(app):
206223
"""Fixture to return an Item payload."""

0 commit comments

Comments
 (0)