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.
Files changed (38) hide show
  1. invenio_app_ils/__init__.py +2 -2
  2. invenio_app_ils/acquisition/indexer.py +72 -0
  3. invenio_app_ils/acquisition/mappings/os-v2/acq_orders/order-v1.0.0.json +5 -0
  4. invenio_app_ils/acquisition/stats/__init__.py +8 -0
  5. invenio_app_ils/acquisition/stats/views.py +82 -0
  6. invenio_app_ils/circulation/errors.py +12 -0
  7. invenio_app_ils/circulation/indexer.py +72 -0
  8. invenio_app_ils/circulation/stats/api.py +1 -1
  9. invenio_app_ils/circulation/stats/views.py +57 -1
  10. invenio_app_ils/config.py +55 -1
  11. invenio_app_ils/document_requests/indexer.py +76 -2
  12. invenio_app_ils/document_requests/mappings/os-v2/document_requests/document_request-v1.0.0.json +5 -0
  13. invenio_app_ils/document_requests/stats/__init__.py +8 -0
  14. invenio_app_ils/document_requests/stats/views.py +85 -0
  15. invenio_app_ils/ext.py +56 -2
  16. invenio_app_ils/permissions.py +3 -0
  17. invenio_app_ils/stats/event_builders.py +44 -0
  18. invenio_app_ils/stats/histogram/__init__.py +18 -0
  19. invenio_app_ils/stats/histogram/api.py +109 -0
  20. invenio_app_ils/stats/histogram/schemas.py +92 -0
  21. invenio_app_ils/stats/histogram/serializers/__init__.py +18 -0
  22. invenio_app_ils/stats/histogram/serializers/response.py +36 -0
  23. invenio_app_ils/stats/histogram/serializers/schema.py +33 -0
  24. invenio_app_ils/stats/histogram/views.py +34 -0
  25. invenio_app_ils/stats/processors.py +56 -0
  26. invenio_app_ils/stats/templates/aggregations/loan_transitions/__init__.py +8 -0
  27. invenio_app_ils/stats/templates/aggregations/loan_transitions/os-v2/__init__.py +8 -0
  28. invenio_app_ils/stats/templates/aggregations/loan_transitions/os-v2/aggr-loan-transitions-v1.json +32 -0
  29. invenio_app_ils/stats/templates/events/loan_transitions/__init__.py +8 -0
  30. invenio_app_ils/stats/templates/events/loan_transitions/os-v2/__init__.py +8 -0
  31. invenio_app_ils/stats/templates/events/loan_transitions/os-v2/loan-transitions-v1.json +40 -0
  32. {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/METADATA +20 -3
  33. {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/RECORD +38 -19
  34. {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/WHEEL +1 -1
  35. {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/entry_points.txt +2 -0
  36. {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/licenses/AUTHORS.rst +0 -0
  37. {invenio_app_ils-6.1.1.dist-info → invenio_app_ils-7.1.0.dist-info}/licenses/LICENSE +0 -0
  38. {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 .circulation.indexer import index_extra_fields_for_loan
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 .documents.api import DOCUMENT_PID_TYPE, Document
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)
@@ -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
@@ -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
+ """Loan transitions aggregation templates."""
@@ -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
+ """Loan transitions aggregation templates."""