invenio-app-ils 6.1.1__py2.py3-none-any.whl → 7.1.0__py2.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.
- invenio_app_ils/__init__.py +2 -2
- invenio_app_ils/acquisition/indexer.py +72 -0
- invenio_app_ils/acquisition/mappings/os-v2/acq_orders/order-v1.0.0.json +5 -0
- invenio_app_ils/acquisition/stats/__init__.py +8 -0
- invenio_app_ils/acquisition/stats/views.py +82 -0
- invenio_app_ils/circulation/errors.py +12 -0
- invenio_app_ils/circulation/indexer.py +72 -0
- invenio_app_ils/circulation/stats/api.py +1 -1
- invenio_app_ils/circulation/stats/views.py +57 -1
- invenio_app_ils/config.py +55 -1
- invenio_app_ils/document_requests/indexer.py +76 -2
- invenio_app_ils/document_requests/mappings/os-v2/document_requests/document_request-v1.0.0.json +5 -0
- invenio_app_ils/document_requests/stats/__init__.py +8 -0
- invenio_app_ils/document_requests/stats/views.py +85 -0
- invenio_app_ils/ext.py +56 -2
- invenio_app_ils/permissions.py +3 -0
- invenio_app_ils/stats/event_builders.py +44 -0
- invenio_app_ils/stats/histogram/__init__.py +18 -0
- invenio_app_ils/stats/histogram/api.py +109 -0
- invenio_app_ils/stats/histogram/schemas.py +92 -0
- invenio_app_ils/stats/histogram/serializers/__init__.py +18 -0
- invenio_app_ils/stats/histogram/serializers/response.py +36 -0
- invenio_app_ils/stats/histogram/serializers/schema.py +33 -0
- invenio_app_ils/stats/histogram/views.py +34 -0
- invenio_app_ils/stats/processors.py +56 -0
- invenio_app_ils/stats/templates/aggregations/loan_transitions/__init__.py +8 -0
- invenio_app_ils/stats/templates/aggregations/loan_transitions/os-v2/__init__.py +8 -0
- invenio_app_ils/stats/templates/aggregations/loan_transitions/os-v2/aggr-loan-transitions-v1.json +32 -0
- invenio_app_ils/stats/templates/events/loan_transitions/__init__.py +8 -0
- invenio_app_ils/stats/templates/events/loan_transitions/os-v2/__init__.py +8 -0
- invenio_app_ils/stats/templates/events/loan_transitions/os-v2/loan-transitions-v1.json +40 -0
- {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/METADATA +20 -3
- {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/RECORD +38 -19
- {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/WHEEL +1 -1
- {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/entry_points.txt +2 -0
- {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/licenses/LICENSE +0 -0
- {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
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 document requests 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.document_requests.api import DOCUMENT_REQUEST_PID_TYPE
|
|
16
|
+
from invenio_app_ils.errors import InvalidParameterError
|
|
17
|
+
from invenio_app_ils.permissions import need_permissions
|
|
18
|
+
from invenio_app_ils.proxies import current_app_ils
|
|
19
|
+
from invenio_app_ils.stats.histogram import (
|
|
20
|
+
HistogramParamsSchema,
|
|
21
|
+
create_histogram_view,
|
|
22
|
+
get_record_statistics,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_document_request_stats_blueprint(app):
|
|
27
|
+
"""Add statistics views to the blueprint."""
|
|
28
|
+
blueprint = Blueprint(
|
|
29
|
+
"invenio_app_ils_document_request_stats", __name__, url_prefix=""
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
create_histogram_view(
|
|
33
|
+
blueprint,
|
|
34
|
+
app,
|
|
35
|
+
DOCUMENT_REQUEST_PID_TYPE,
|
|
36
|
+
DocumentRequestHistogramResource,
|
|
37
|
+
"/document-requests",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return blueprint
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DocumentRequestHistogramResource(ContentNegotiatedMethodView):
|
|
44
|
+
"""Document request stats resource."""
|
|
45
|
+
|
|
46
|
+
view_name = "document_request_histogram"
|
|
47
|
+
|
|
48
|
+
def __init__(self, serializers, ctx, *args, **kwargs):
|
|
49
|
+
"""Constructor."""
|
|
50
|
+
super().__init__(serializers, *args, **kwargs)
|
|
51
|
+
for key, value in ctx.items():
|
|
52
|
+
setattr(self, key, value)
|
|
53
|
+
|
|
54
|
+
@need_permissions("stats-document-requests")
|
|
55
|
+
def get(self, **kwargs):
|
|
56
|
+
"""Get document request statistics."""
|
|
57
|
+
|
|
58
|
+
document_request_date_fields = [
|
|
59
|
+
"_created",
|
|
60
|
+
"_updated",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
schema = HistogramParamsSchema(document_request_date_fields)
|
|
64
|
+
try:
|
|
65
|
+
parsed_args = schema.load(request.args.to_dict())
|
|
66
|
+
except ValidationError as e:
|
|
67
|
+
raise InvalidParameterError(description=e.messages) from e
|
|
68
|
+
|
|
69
|
+
# Construct search to allow for filtering with the q parameter
|
|
70
|
+
search_cls = current_app_ils.document_request_search_cls
|
|
71
|
+
search = search_cls()
|
|
72
|
+
search, _ = default_search_factory(self, search)
|
|
73
|
+
|
|
74
|
+
aggregation_buckets = get_record_statistics(
|
|
75
|
+
document_request_date_fields,
|
|
76
|
+
search,
|
|
77
|
+
parsed_args["group_by"],
|
|
78
|
+
parsed_args["metrics"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
response = {
|
|
82
|
+
"buckets": aggregation_buckets,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return self.make_response(response, 200)
|
invenio_app_ils/ext.py
CHANGED
|
@@ -20,10 +20,15 @@ from invenio_app_ils.records.metadata_extensions import (
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
from .circulation import config as circulation_config
|
|
23
|
-
from .
|
|
23
|
+
from .acquisition.indexer import index_stats_fields_for_order
|
|
24
|
+
from .circulation.indexer import (
|
|
25
|
+
index_extra_fields_for_loan,
|
|
26
|
+
index_stats_fields_for_loan,
|
|
27
|
+
)
|
|
24
28
|
from .circulation.receivers import register_circulation_signals
|
|
25
29
|
from .document_requests.api import DOCUMENT_REQUEST_PID_TYPE
|
|
26
|
-
from .
|
|
30
|
+
from .document_requests.indexer import index_stats_fields_for_document_request
|
|
31
|
+
from .documents.api import DOCUMENT_PID_TYPE
|
|
27
32
|
from .eitems.api import EITEM_PID_TYPE
|
|
28
33
|
from .files.receivers import register_files_signals
|
|
29
34
|
from .internal_locations.api import INTERNAL_LOCATION_PID_TYPE
|
|
@@ -221,6 +226,8 @@ class InvenioAppIls(object):
|
|
|
221
226
|
self.init_app(app)
|
|
222
227
|
self.init_metadata_extensions(app)
|
|
223
228
|
self.init_loan_indexer_hook(app)
|
|
229
|
+
self.init_order_indexer_hook(app)
|
|
230
|
+
self.init_document_request_indexer_hook(app)
|
|
224
231
|
|
|
225
232
|
def init_app(self, app):
|
|
226
233
|
"""Flask application initialization."""
|
|
@@ -275,6 +282,24 @@ class InvenioAppIls(object):
|
|
|
275
282
|
index="{0}s-{0}-v1.0.0".format("loan"),
|
|
276
283
|
)
|
|
277
284
|
|
|
285
|
+
def init_order_indexer_hook(self, app):
|
|
286
|
+
"""Custom order indexer hook init."""
|
|
287
|
+
before_record_index.dynamic_connect(
|
|
288
|
+
before_order_index_hook,
|
|
289
|
+
sender=app,
|
|
290
|
+
weak=False,
|
|
291
|
+
index="acq_orders-order-v1.0.0",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def init_document_request_indexer_hook(self, app):
|
|
295
|
+
"""Custom document request indexer hook init."""
|
|
296
|
+
before_record_index.dynamic_connect(
|
|
297
|
+
before_document_request_index_hook,
|
|
298
|
+
sender=app,
|
|
299
|
+
weak=False,
|
|
300
|
+
index="document_requests-document_request-v1.0.0",
|
|
301
|
+
)
|
|
302
|
+
|
|
278
303
|
def update_config_records_rest(self, app):
|
|
279
304
|
"""Merge overridden circ records rest into global records rest."""
|
|
280
305
|
for k in dir(circulation_config):
|
|
@@ -327,3 +352,32 @@ def before_loan_index_hook(sender, json=None, record=None, index=None, **kwargs)
|
|
|
327
352
|
:param kwargs: Any other parameters.
|
|
328
353
|
"""
|
|
329
354
|
index_extra_fields_for_loan(json)
|
|
355
|
+
if current_app.config["ILS_EXTEND_INDICES_WITH_STATS_ENABLED"]:
|
|
356
|
+
index_stats_fields_for_loan(json)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def before_order_index_hook(sender, json=None, record=None, index=None, **kwargs):
|
|
360
|
+
"""Hook to transform order record before ES indexing.
|
|
361
|
+
|
|
362
|
+
:param sender: The entity sending the signal.
|
|
363
|
+
:param json: The dumped Record dict which will be indexed.
|
|
364
|
+
:param record: The corresponding Record object.
|
|
365
|
+
:param index: The index in which the json will be indexed.
|
|
366
|
+
:param kwargs: Any other parameters.
|
|
367
|
+
"""
|
|
368
|
+
if current_app.config["ILS_EXTEND_INDICES_WITH_STATS_ENABLED"]:
|
|
369
|
+
index_stats_fields_for_order(json)
|
|
370
|
+
|
|
371
|
+
def before_document_request_index_hook(
|
|
372
|
+
sender, json=None, record=None, index=None, **kwargs
|
|
373
|
+
):
|
|
374
|
+
"""Hook to transform document request record before ES indexing.
|
|
375
|
+
|
|
376
|
+
:param sender: The entity sending the signal.
|
|
377
|
+
:param json: The dumped Record dict which will be indexed.
|
|
378
|
+
:param record: The corresponding Record object.
|
|
379
|
+
:param index: The index in which the json will be indexed.
|
|
380
|
+
:param kwargs: Any other parameters.
|
|
381
|
+
"""
|
|
382
|
+
if current_app.config["ILS_EXTEND_INDICES_WITH_STATS_ENABLED"]:
|
|
383
|
+
index_stats_fields_for_document_request(json)
|
invenio_app_ils/permissions.py
CHANGED
|
@@ -205,6 +205,9 @@ _is_backoffice_permission = [
|
|
|
205
205
|
]
|
|
206
206
|
_is_backoffice_read_permission = [
|
|
207
207
|
"stats-most-loaned",
|
|
208
|
+
"stats-loans",
|
|
209
|
+
"stats-orders",
|
|
210
|
+
"stats-document-requests",
|
|
208
211
|
"get-notifications-sent-to-patron",
|
|
209
212
|
]
|
|
210
213
|
_is_patron_owner_permission = [
|
|
@@ -14,6 +14,7 @@ from flask import g
|
|
|
14
14
|
from flask_login import current_user
|
|
15
15
|
|
|
16
16
|
from invenio_app_ils.permissions import backoffice_permission
|
|
17
|
+
from invenio_app_ils.proxies import current_app_ils
|
|
17
18
|
from invenio_app_ils.records.api import IlsRecord
|
|
18
19
|
|
|
19
20
|
|
|
@@ -71,3 +72,46 @@ def add_record_pid_to_event(event, sender_app, record=None, **kwargs):
|
|
|
71
72
|
event.update({"pid_value": record.get("pid")})
|
|
72
73
|
|
|
73
74
|
return event
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def loan_transition_event_builder(
|
|
78
|
+
event,
|
|
79
|
+
sender_app,
|
|
80
|
+
transition=None,
|
|
81
|
+
initial_loan=None,
|
|
82
|
+
loan=None,
|
|
83
|
+
trigger=None,
|
|
84
|
+
**kwargs
|
|
85
|
+
):
|
|
86
|
+
"""Build an event for a loan state transition."""
|
|
87
|
+
event.update(
|
|
88
|
+
{
|
|
89
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc)
|
|
90
|
+
.replace(tzinfo=None)
|
|
91
|
+
.isoformat(),
|
|
92
|
+
"trigger": trigger,
|
|
93
|
+
"pid_value": loan["pid"],
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if trigger == "request":
|
|
98
|
+
# Store how many items were available during request.
|
|
99
|
+
# This information is used by the loan indexer and added to the loan.
|
|
100
|
+
document_pid = loan["document_pid"]
|
|
101
|
+
document_class = current_app_ils.document_record_cls
|
|
102
|
+
document = document_class.get_record_by_pid(document_pid)
|
|
103
|
+
document_dict = document.replace_refs()
|
|
104
|
+
|
|
105
|
+
available_items_during_request_count = document_dict["circulation"][
|
|
106
|
+
"available_items_for_loan_count"
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
event.update(
|
|
110
|
+
{
|
|
111
|
+
"extra_data": {
|
|
112
|
+
"available_items_during_request_count": available_items_during_request_count
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return event
|
|
@@ -0,0 +1,18 @@
|
|
|
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 histogram statistics."""
|
|
9
|
+
|
|
10
|
+
from invenio_app_ils.stats.histogram.api import get_record_statistics
|
|
11
|
+
from invenio_app_ils.stats.histogram.schemas import HistogramParamsSchema
|
|
12
|
+
from invenio_app_ils.stats.histogram.views import create_histogram_view
|
|
13
|
+
|
|
14
|
+
__all__ = (
|
|
15
|
+
"get_record_statistics",
|
|
16
|
+
"HistogramParamsSchema",
|
|
17
|
+
"create_histogram_view",
|
|
18
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2019-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
|
+
"""APIs for ILS histogram statistics."""
|
|
9
|
+
|
|
10
|
+
from invenio_search.engine import dsl
|
|
11
|
+
|
|
12
|
+
from invenio_app_ils.stats.histogram.schemas import (
|
|
13
|
+
_OS_NATIVE_AGGREGATE_FUNCTION_TYPES,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _generate_metric_agg_field_name(metric):
|
|
18
|
+
"""Return the aggregation name used for a metric.
|
|
19
|
+
|
|
20
|
+
:param metric: Must include 'field' and 'aggregation' keys.
|
|
21
|
+
:returns: The aggregation field name in the form '<aggregation>_<field>'.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
return f"{metric['aggregation']}__{metric['field']}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_record_statistics(date_fields, search, requested_group_by, requested_metrics):
|
|
28
|
+
"""Aggregate record statistics for requested metrics.
|
|
29
|
+
|
|
30
|
+
:param date_fields: List of date fields for the record type.
|
|
31
|
+
Date fields require different handling when using them to group by.
|
|
32
|
+
:param search: The base search object to apply aggregations on.
|
|
33
|
+
:param requested_group_by: List of group dictionaries with 'field' and optional 'interval' keys.
|
|
34
|
+
Example: [{"field": "start_date", "interval": "monthly"}, {"field": "state"}]
|
|
35
|
+
:param requested_metrics: List of metric dictionaries with 'field' and 'aggregation' keys.
|
|
36
|
+
Example: [{"field": "loan_duration", "aggregation": "avg"}]
|
|
37
|
+
:returns: OpenSearch aggregation results with multi-terms histogram and optional metrics
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Build composite aggregation
|
|
41
|
+
sources = []
|
|
42
|
+
for grouping in requested_group_by:
|
|
43
|
+
grouping_field = grouping["field"]
|
|
44
|
+
|
|
45
|
+
if grouping_field in date_fields:
|
|
46
|
+
sources.append(
|
|
47
|
+
{
|
|
48
|
+
grouping_field: {
|
|
49
|
+
"date_histogram": {
|
|
50
|
+
"field": grouping_field,
|
|
51
|
+
"calendar_interval": grouping["interval"],
|
|
52
|
+
"format": "yyyy-MM-dd",
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
sources.append({grouping_field: {"terms": {"field": grouping_field}}})
|
|
59
|
+
|
|
60
|
+
composite_agg = dsl.A("composite", sources=sources, size=1000)
|
|
61
|
+
|
|
62
|
+
for metric in requested_metrics:
|
|
63
|
+
agg_name = _generate_metric_agg_field_name(metric)
|
|
64
|
+
|
|
65
|
+
grouping_field = metric["field"]
|
|
66
|
+
agg_type = metric["aggregation"]
|
|
67
|
+
field_config = {"field": grouping_field}
|
|
68
|
+
if agg_type in _OS_NATIVE_AGGREGATE_FUNCTION_TYPES:
|
|
69
|
+
composite_agg = composite_agg.metric(
|
|
70
|
+
agg_name, dsl.A(agg_type, **field_config)
|
|
71
|
+
)
|
|
72
|
+
elif agg_type == "median":
|
|
73
|
+
composite_agg = composite_agg.metric(
|
|
74
|
+
agg_name, dsl.A("percentiles", percents=[50], **field_config)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
search.aggs.bucket("aggregations", composite_agg)
|
|
78
|
+
|
|
79
|
+
# Only retrieve aggregation results
|
|
80
|
+
search = search[:0]
|
|
81
|
+
result = search.execute()
|
|
82
|
+
|
|
83
|
+
# Parse aggregation results
|
|
84
|
+
buckets = []
|
|
85
|
+
if hasattr(result.aggregations, "aggregations"):
|
|
86
|
+
for bucket in getattr(result.aggregations, "aggregations").buckets:
|
|
87
|
+
metrics_data = {}
|
|
88
|
+
for metric in requested_metrics:
|
|
89
|
+
agg_name = _generate_metric_agg_field_name(metric)
|
|
90
|
+
|
|
91
|
+
if hasattr(bucket, agg_name):
|
|
92
|
+
agg_result = getattr(bucket, agg_name)
|
|
93
|
+
agg_type = metric["aggregation"]
|
|
94
|
+
|
|
95
|
+
if agg_type in _OS_NATIVE_AGGREGATE_FUNCTION_TYPES:
|
|
96
|
+
metrics_data[agg_name] = agg_result.value
|
|
97
|
+
elif agg_type == "median":
|
|
98
|
+
median_value = agg_result.values.get("50.0")
|
|
99
|
+
metrics_data[agg_name] = median_value
|
|
100
|
+
|
|
101
|
+
bucket_data = {
|
|
102
|
+
"key": bucket.key.to_dict(),
|
|
103
|
+
"doc_count": bucket.doc_count,
|
|
104
|
+
"metrics": metrics_data,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
buckets.append(bucket_data)
|
|
108
|
+
|
|
109
|
+
return buckets
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
"""Marshmallow schemas for histogram statistics validation."""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from marshmallow import (
|
|
14
|
+
Schema,
|
|
15
|
+
ValidationError,
|
|
16
|
+
fields,
|
|
17
|
+
pre_load,
|
|
18
|
+
validate,
|
|
19
|
+
validates_schema,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_OS_VALID_FIELD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.]+$")
|
|
24
|
+
_OS_NATIVE_AGGREGATE_FUNCTION_TYPES = {"avg", "sum", "min", "max"}
|
|
25
|
+
_VALID_AGGREGATE_FUNCTION_TYPES = _OS_NATIVE_AGGREGATE_FUNCTION_TYPES.union({"median"})
|
|
26
|
+
_VALID_DATE_INTERVALS = {"1d", "1w", "1M", "1q", "1y"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SecureSearchFieldNameField(fields.String):
|
|
30
|
+
"""Field that validates field names for search to prevent injection attacks."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *args, **kwargs):
|
|
33
|
+
kwargs["validate"] = validate.Regexp(_OS_VALID_FIELD_NAME_PATTERN)
|
|
34
|
+
super().__init__(*args, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GroupByItemSchema(Schema):
|
|
38
|
+
"""Schema for validating a single group_by item."""
|
|
39
|
+
|
|
40
|
+
field = SecureSearchFieldNameField(required=True)
|
|
41
|
+
interval = fields.String(validate=validate.OneOf(_VALID_DATE_INTERVALS))
|
|
42
|
+
|
|
43
|
+
@validates_schema
|
|
44
|
+
def validate_date_fields(self, data, **kwargs):
|
|
45
|
+
"""Validate that date fields have an interval and non-date fields do not."""
|
|
46
|
+
|
|
47
|
+
date_fields = self.context["date_fields"]
|
|
48
|
+
field = data.get("field")
|
|
49
|
+
interval = data.get("interval")
|
|
50
|
+
if field in date_fields and not interval:
|
|
51
|
+
raise ValidationError(
|
|
52
|
+
{"interval": ["Interval is required for date fields."]}
|
|
53
|
+
)
|
|
54
|
+
if field not in date_fields and interval is not None:
|
|
55
|
+
raise ValidationError(
|
|
56
|
+
{"interval": ["Interval must not be provided for non-date fields."]}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MetricItemSchema(Schema):
|
|
61
|
+
"""Schema for validating a single metric item."""
|
|
62
|
+
|
|
63
|
+
field = SecureSearchFieldNameField(required=True)
|
|
64
|
+
aggregation = fields.String(
|
|
65
|
+
required=True, validate=validate.OneOf(_VALID_AGGREGATE_FUNCTION_TYPES)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HistogramParamsSchema(Schema):
|
|
70
|
+
"""Schema for validating the query string parameters for the histogram endpoint"""
|
|
71
|
+
|
|
72
|
+
metrics = fields.List(fields.Nested(MetricItemSchema), required=False)
|
|
73
|
+
group_by = fields.List(
|
|
74
|
+
fields.Nested(GroupByItemSchema), required=True, validate=validate.Length(min=1)
|
|
75
|
+
)
|
|
76
|
+
q = fields.String()
|
|
77
|
+
|
|
78
|
+
def __init__(self, date_fields, *args, **kwargs):
|
|
79
|
+
super().__init__(*args, **kwargs)
|
|
80
|
+
self.context = {"date_fields": set(date_fields)}
|
|
81
|
+
|
|
82
|
+
@pre_load
|
|
83
|
+
def parse_query_string(self, data, **kwargs):
|
|
84
|
+
"""Parse the metrics and group_by parameters from JSON strings."""
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
for key in ("metrics", "group_by"):
|
|
88
|
+
# default value as the field "metrics" is not required
|
|
89
|
+
data[key] = json.loads(data.get(key, "[]"))
|
|
90
|
+
except Exception as e:
|
|
91
|
+
raise ValidationError from e
|
|
92
|
+
return data
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# This file is part of Invenio.
|
|
4
|
+
# Copyright (C) 2025-2025 CERN.
|
|
5
|
+
#
|
|
6
|
+
# Invenio is free software; you can redistribute it and/or modify it
|
|
7
|
+
# under the terms of the MIT License; see LICENSE file for more details.
|
|
8
|
+
|
|
9
|
+
"""Invenio App ILS histogram stats serializers."""
|
|
10
|
+
|
|
11
|
+
from invenio_app_ils.stats.histogram.serializers.response import (
|
|
12
|
+
histogram_stats_responsify,
|
|
13
|
+
)
|
|
14
|
+
from invenio_app_ils.stats.histogram.serializers.schema import HistogramStatsV1
|
|
15
|
+
|
|
16
|
+
histogram_stats_response = histogram_stats_responsify(
|
|
17
|
+
HistogramStatsV1, "application/json"
|
|
18
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# This file is part of Invenio.
|
|
4
|
+
# Copyright (C) 2025-2025 CERN.
|
|
5
|
+
#
|
|
6
|
+
# Invenio is free software; you can redistribute it and/or modify it
|
|
7
|
+
# under the terms of the MIT License; see LICENSE file for more details.
|
|
8
|
+
|
|
9
|
+
"""Invenio App ILS histogram stats response serializers."""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from flask import current_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def histogram_stats_responsify(schema_class, mimetype):
|
|
17
|
+
"""Histogram stats response serializer.
|
|
18
|
+
|
|
19
|
+
:param schema_class: Schema instance.
|
|
20
|
+
:param mimetype: MIME type of response.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def view(data, code=200, headers=None):
|
|
24
|
+
"""Generate the response object."""
|
|
25
|
+
response_data = schema_class().dump(data)
|
|
26
|
+
|
|
27
|
+
response = current_app.response_class(
|
|
28
|
+
json.dumps(response_data), mimetype=mimetype
|
|
29
|
+
)
|
|
30
|
+
response.status_code = code
|
|
31
|
+
|
|
32
|
+
if headers is not None:
|
|
33
|
+
response.headers.extend(headers)
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
return view
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# This file is part of Invenio.
|
|
4
|
+
# Copyright (C) 2025-2025 CERN.
|
|
5
|
+
#
|
|
6
|
+
# Invenio is free software; you can redistribute it and/or modify it
|
|
7
|
+
# under the terms of the MIT License; see LICENSE file for more details.
|
|
8
|
+
|
|
9
|
+
"""Invenio App ILS histogram stats serializers schema."""
|
|
10
|
+
|
|
11
|
+
from marshmallow import Schema, fields
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BucketSchema(Schema):
|
|
15
|
+
"""Schema for a single histogram bucket."""
|
|
16
|
+
|
|
17
|
+
doc_count = fields.Int(required=True)
|
|
18
|
+
key = fields.Dict(keys=fields.String(), values=fields.String())
|
|
19
|
+
|
|
20
|
+
metrics = fields.Dict(
|
|
21
|
+
keys=fields.String(),
|
|
22
|
+
values=fields.Float(),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HistogramStatsV1(Schema):
|
|
27
|
+
"""Schema for a stats histogram response."""
|
|
28
|
+
|
|
29
|
+
buckets = fields.List(
|
|
30
|
+
fields.Nested(BucketSchema),
|
|
31
|
+
required=True,
|
|
32
|
+
description="Statistics buckets.",
|
|
33
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
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 histogram stats views."""
|
|
9
|
+
|
|
10
|
+
from invenio_app_ils.stats.histogram.serializers import histogram_stats_response
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_histogram_view(blueprint, app, pid_type, resource_cls, url_prefix):
|
|
14
|
+
"""Add url rule for histogram view."""
|
|
15
|
+
|
|
16
|
+
assert url_prefix.startswith("/"), "url_prefix must start with /"
|
|
17
|
+
assert not url_prefix.endswith("/"), "url_prefix must not end with /"
|
|
18
|
+
|
|
19
|
+
endpoints = app.config.get("RECORDS_REST_ENDPOINTS")
|
|
20
|
+
record_endpoint = endpoints.get(pid_type)
|
|
21
|
+
default_media_type = record_endpoint.get("default_media_type")
|
|
22
|
+
histogram_serializers = {"application/json": histogram_stats_response}
|
|
23
|
+
|
|
24
|
+
histogram_stats_view_func = resource_cls.as_view(
|
|
25
|
+
resource_cls.view_name,
|
|
26
|
+
serializers=histogram_serializers,
|
|
27
|
+
default_media_type=default_media_type,
|
|
28
|
+
ctx={},
|
|
29
|
+
)
|
|
30
|
+
blueprint.add_url_rule(
|
|
31
|
+
f"{url_prefix}/stats",
|
|
32
|
+
view_func=histogram_stats_view_func,
|
|
33
|
+
methods=["GET"],
|
|
34
|
+
)
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
"""ILS stats preprocessors."""
|
|
10
10
|
|
|
11
|
+
from invenio_circulation.proxies import current_circulation
|
|
12
|
+
from invenio_search.engine import search
|
|
13
|
+
from invenio_stats.processors import EventsIndexer
|
|
14
|
+
|
|
15
|
+
from invenio_app_ils.indexer import wait_es_refresh
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
def add_record_change_ids(doc):
|
|
13
19
|
"""Add unique_id and aggregation_id to the doc."""
|
|
@@ -28,3 +34,53 @@ def add_record_change_ids(doc):
|
|
|
28
34
|
doc["unique_id"] += f"__{doc['user_id']}"
|
|
29
35
|
|
|
30
36
|
return doc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def add_loan_transition_unique_id(doc):
|
|
40
|
+
"""Add unique_id to the doc for a loan transition event."""
|
|
41
|
+
|
|
42
|
+
doc["unique_id"] = f"{doc['pid_value']}__{doc['trigger']}"
|
|
43
|
+
|
|
44
|
+
return doc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LoansEventsIndexer(EventsIndexer):
|
|
48
|
+
"""Events indexer for events related to loans.
|
|
49
|
+
|
|
50
|
+
Triggers a reindex on affected loans
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def run(self):
|
|
54
|
+
"""Process events queue and reindex affected loans.
|
|
55
|
+
|
|
56
|
+
First index invenio-stats events that are related to loans.
|
|
57
|
+
Afterwards trigger a reindex of the loans for which an event occurred.
|
|
58
|
+
The loan indexer can then consume the updated invenio-stats events index.
|
|
59
|
+
This reindex is triggered so the loan index has up-to-date information.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
When a loan is requested, an event is placed in the queue that stores the
|
|
63
|
+
loan PID and how many items were available at the time of the request.
|
|
64
|
+
When the event is indexed with this class, it is moved from the queue into
|
|
65
|
+
the events index. Afterwards, the loan is reindexed and, during this
|
|
66
|
+
process, the loan indexer gets the state of the document from the events index.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# Collect all loan events that occurred from the queue and index them
|
|
70
|
+
actions = [action for action in self.actionsiter()]
|
|
71
|
+
res = search.helpers.bulk(self.client, actions, stats_only=True, chunk_size=50)
|
|
72
|
+
|
|
73
|
+
# Refresh changed event indices so new entries are immediately available
|
|
74
|
+
indices = {action["_index"] for action in actions}
|
|
75
|
+
for index in indices:
|
|
76
|
+
wait_es_refresh(index)
|
|
77
|
+
|
|
78
|
+
# Reindex loans that had events to ensure their index contains the most recent information
|
|
79
|
+
loan_pids = {action["_source"]["pid_value"] for action in actions}
|
|
80
|
+
loan_indexer = current_circulation.loan_indexer()
|
|
81
|
+
loan_cls = current_circulation.loan_record_cls
|
|
82
|
+
for loan_pid in loan_pids:
|
|
83
|
+
loan = loan_cls.get_record_by_pid(loan_pid)
|
|
84
|
+
loan_indexer.index(loan)
|
|
85
|
+
|
|
86
|
+
return res
|