invenio-app-ils 4.2.0__py2.py3-none-any.whl → 4.4.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.
@@ -7,6 +7,6 @@
7
7
 
8
8
  """invenio-app-ils."""
9
9
 
10
- __version__ = "4.2.0"
10
+ __version__ = "4.4.0"
11
11
 
12
12
  __all__ = ("__version__",)
@@ -30,10 +30,15 @@ from invenio_app_ils.circulation.search import (
30
30
  get_all_expiring_or_overdue_loans_by_patron_pid,
31
31
  )
32
32
  from invenio_app_ils.errors import (
33
+ DocumentOverbookedError,
33
34
  IlsException,
34
35
  InvalidLoanExtendError,
35
36
  InvalidParameterError,
37
+ ItemCannotCirculateError,
38
+ ItemHasActiveLoanError,
39
+ ItemNotFoundError,
36
40
  MissingRequiredParameterError,
41
+ MultipleItemsBarcodeFoundError,
37
42
  PatronHasLoanOnDocumentError,
38
43
  PatronHasLoanOnItemError,
39
44
  PatronHasRequestOnDocumentError,
@@ -119,7 +124,7 @@ def request_loan(
119
124
  patron_pid,
120
125
  transaction_location_pid,
121
126
  transaction_user_pid=None,
122
- **kwargs
127
+ **kwargs,
123
128
  ):
124
129
  """Create a new loan and trigger the first transition to PENDING."""
125
130
  loan_cls = current_circulation.loan_record_cls
@@ -170,13 +175,54 @@ def patron_has_active_loan_on_item(patron_pid, item_pid):
170
175
  return search_result.hits.total.value > 0
171
176
 
172
177
 
178
+ def _checkout_loan(
179
+ item_pid,
180
+ patron_pid,
181
+ transaction_location_pid,
182
+ trigger="checkout",
183
+ transaction_user_pid=None,
184
+ delivery=None,
185
+ **kwargs,
186
+ ):
187
+ """Checkout a loan."""
188
+ transaction_user_pid = transaction_user_pid or str(current_user.id)
189
+ loan_cls = current_circulation.loan_record_cls
190
+ # create a new loan
191
+ record_uuid = uuid.uuid4()
192
+ new_loan = dict(
193
+ patron_pid=patron_pid,
194
+ transaction_location_pid=transaction_location_pid,
195
+ transaction_user_pid=transaction_user_pid,
196
+ )
197
+
198
+ if delivery:
199
+ new_loan["delivery"] = delivery
200
+ # check if there is an existing request
201
+ loan = patron_has_request_on_document(patron_pid, kwargs.get("document_pid"))
202
+ if loan:
203
+ loan = loan_cls.get_record_by_pid(loan.pid)
204
+ pid = IlsCirculationLoanIdProvider.get(loan["pid"]).pid
205
+ loan.update(new_loan)
206
+ else:
207
+ pid = ils_circulation_loan_pid_minter(record_uuid, data=new_loan)
208
+ loan = loan_cls.create(data=new_loan, id_=record_uuid)
209
+
210
+ params = deepcopy(loan)
211
+ params.update(item_pid=item_pid, **kwargs)
212
+
213
+ loan = current_circulation.circulation.trigger(
214
+ loan, **dict(params, trigger=trigger)
215
+ )
216
+ return pid, loan
217
+
218
+
173
219
  def checkout_loan(
174
220
  item_pid,
175
221
  patron_pid,
176
222
  transaction_location_pid,
177
223
  transaction_user_pid=None,
178
224
  force=False,
179
- **kwargs
225
+ **kwargs,
180
226
  ):
181
227
  """Create a new loan and trigger the first transition to ITEM_ON_LOAN.
182
228
 
@@ -191,7 +237,7 @@ def checkout_loan(
191
237
  the checkout. If False, the checkout will fail when the item cannot
192
238
  circulate.
193
239
  """
194
- loan_cls = current_circulation.loan_record_cls
240
+
195
241
  if patron_has_active_loan_on_item(patron_pid=patron_pid, item_pid=item_pid):
196
242
  raise PatronHasLoanOnItemError(patron_pid, item_pid)
197
243
  optional_delivery = kwargs.get("delivery")
@@ -201,35 +247,86 @@ def checkout_loan(
201
247
  if force:
202
248
  _set_item_to_can_circulate(item_pid)
203
249
 
204
- transaction_user_pid = transaction_user_pid or str(current_user.id)
205
-
206
- # create a new loan
207
- record_uuid = uuid.uuid4()
208
- new_loan = dict(
209
- patron_pid=patron_pid,
210
- transaction_location_pid=transaction_location_pid,
250
+ return _checkout_loan(
251
+ item_pid,
252
+ patron_pid,
253
+ transaction_location_pid,
211
254
  transaction_user_pid=transaction_user_pid,
255
+ **kwargs,
212
256
  )
213
257
 
214
- # check if there is an existing request
215
- loan = patron_has_request_on_document(patron_pid, kwargs.get("document_pid"))
216
- if loan:
217
- loan = loan_cls.get_record_by_pid(loan.pid)
218
- pid = IlsCirculationLoanIdProvider.get(loan["pid"]).pid
219
- loan.update(new_loan)
220
- else:
221
- pid = ils_circulation_loan_pid_minter(record_uuid, data=new_loan)
222
- loan = loan_cls.create(data=new_loan, id_=record_uuid)
223
258
 
224
- params = deepcopy(loan)
225
- params.update(item_pid=item_pid, **kwargs)
259
+ def _ensure_item_loanable_via_self_checkout(item_pid):
260
+ """Self-checkout: return loanable item or raise when not loanable.
226
261
 
227
- # trigger the transition to request
228
- loan = current_circulation.circulation.trigger(
229
- loan, **dict(params, trigger="checkout")
262
+ Implements the self-checkout rules to loan an item.
263
+ """
264
+ item = current_app_ils.item_record_cls.get_record_by_pid(item_pid)
265
+ item_dict = item.replace_refs()
266
+
267
+ if item_dict["status"] != "CAN_CIRCULATE":
268
+ raise ItemCannotCirculateError()
269
+
270
+ circulation_state = item_dict["circulation"].get("state")
271
+ has_active_loan = (
272
+ circulation_state and circulation_state in CIRCULATION_STATES_LOAN_ACTIVE
230
273
  )
274
+ if has_active_loan:
275
+ raise ItemHasActiveLoanError(loan_pid=item_dict["circulation"]["loan_pid"])
231
276
 
232
- return pid, loan
277
+ document = current_app_ils.document_record_cls.get_record_by_pid(
278
+ item_dict["document_pid"]
279
+ )
280
+ document_dict = document.replace_refs()
281
+ if document_dict["circulation"].get("overbooked", False):
282
+ raise DocumentOverbookedError(
283
+ f"Cannot self-checkout the overbooked document {item_dict['document_pid']}"
284
+ )
285
+
286
+ return item
287
+
288
+
289
+ def self_checkout_get_item_by_barcode(barcode):
290
+ """Search for an item by barcode.
291
+
292
+ :param barcode: the barcode of the item to search for
293
+ :return item: the item that was found, or raise in case of errors
294
+ """
295
+ item_search = current_app_ils.item_search_cls()
296
+ items = item_search.search_by_barcode(barcode).execute()
297
+ if items.hits.total.value == 0:
298
+ raise ItemNotFoundError(barcode=barcode)
299
+ if items.hits.total.value > 1:
300
+ raise MultipleItemsBarcodeFoundError(barcode)
301
+
302
+ item_pid = items.hits[0].pid
303
+ item = _ensure_item_loanable_via_self_checkout(item_pid)
304
+ return item_pid, item
305
+
306
+
307
+ def self_checkout(
308
+ item_pid, patron_pid, transaction_location_pid, transaction_user_pid=None, **kwargs
309
+ ):
310
+ """Perform self-checkout.
311
+
312
+ :param item_pid: a dict containing `value` and `type` fields to
313
+ uniquely identify the item.
314
+ :param patron_pid: the PID value of the patron
315
+ :param transaction_location_pid: the PID value of the location where the
316
+ checkout is performed
317
+ :param transaction_user_pid: the PID value of the user that performed the
318
+ checkout
319
+ """
320
+ _ensure_item_loanable_via_self_checkout(item_pid["value"])
321
+ return _checkout_loan(
322
+ item_pid,
323
+ patron_pid,
324
+ transaction_location_pid,
325
+ transaction_user_pid=transaction_user_pid,
326
+ trigger="self_checkout",
327
+ delivery=dict(method="SELF-CHECKOUT"),
328
+ **kwargs,
329
+ )
233
330
 
234
331
 
235
332
  def bulk_extend_loans(patron_pid, **kwargs):
@@ -253,7 +350,7 @@ def bulk_extend_loans(patron_pid, **kwargs):
253
350
  params,
254
351
  trigger="extend",
255
352
  transition_kwargs=dict(send_notification=False),
256
- )
353
+ ),
257
354
  )
258
355
  extended_loans.append(extended_loan)
259
356
  except (CirculationException, InvalidLoanExtendError):
@@ -44,7 +44,6 @@ from invenio_app_ils.permissions import (
44
44
  PatronOwnerPermission,
45
45
  authenticated_user_permission,
46
46
  backoffice_permission,
47
- loan_checkout_permission,
48
47
  loan_extend_circulation_permission,
49
48
  patron_owner_permission,
50
49
  superuser_permission,
@@ -84,6 +83,7 @@ ILS_CIRCULATION_LOAN_WILL_EXPIRE_DAYS = 7
84
83
  ILS_CIRCULATION_DELIVERY_METHODS = {
85
84
  "PICKUP": "Pick it up at the library desk",
86
85
  "DELIVERY": "Have it delivered to my office",
86
+ "SELF-CHECKOUT": "Self-checkout",
87
87
  }
88
88
 
89
89
  # Notification message creator for loan notifications
@@ -162,7 +162,13 @@ CIRCULATION_LOAN_TRANSITIONS = {
162
162
  dest="ITEM_ON_LOAN",
163
163
  trigger="checkout",
164
164
  transition=ILSToItemOnLoan,
165
- permission_factory=loan_checkout_permission,
165
+ permission_factory=backoffice_permission,
166
+ ),
167
+ dict(
168
+ dest="ITEM_ON_LOAN",
169
+ trigger="self_checkout",
170
+ transition=ILSToItemOnLoan,
171
+ permission_factory=authenticated_user_permission,
166
172
  ),
167
173
  ],
168
174
  "PENDING": [
@@ -172,6 +178,12 @@ CIRCULATION_LOAN_TRANSITIONS = {
172
178
  transition=ILSToItemOnLoan,
173
179
  permission_factory=backoffice_permission,
174
180
  ),
181
+ dict(
182
+ dest="ITEM_ON_LOAN",
183
+ trigger="self_checkout",
184
+ transition=ILSToItemOnLoan,
185
+ permission_factory=authenticated_user_permission,
186
+ ),
175
187
  dict(
176
188
  dest="CANCELLED",
177
189
  trigger="cancel",
@@ -12,9 +12,11 @@ from invenio_app_ils.records.loaders import ils_marshmallow_loader
12
12
  from .schemas.json.bulk_extend import BulkExtendLoansSchemaV1
13
13
  from .schemas.json.loan_checkout import LoanCheckoutSchemaV1
14
14
  from .schemas.json.loan_request import LoanRequestSchemaV1
15
+ from .schemas.json.loan_self_checkout import LoanSelfCheckoutSchemaV1
15
16
  from .schemas.json.loan_update_dates import LoanUpdateDatesSchemaV1
16
17
 
17
18
  loan_request_loader = ils_marshmallow_loader(LoanRequestSchemaV1)
18
19
  loan_checkout_loader = ils_marshmallow_loader(LoanCheckoutSchemaV1)
20
+ loan_self_checkout_loader = ils_marshmallow_loader(LoanSelfCheckoutSchemaV1)
19
21
  loan_update_dates_loader = ils_marshmallow_loader(LoanUpdateDatesSchemaV1)
20
22
  loans_bulk_update_loader = ils_marshmallow_loader(BulkExtendLoansSchemaV1)
@@ -0,0 +1,19 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (C) 2019 CERN.
4
+ #
5
+ # invenio-app-ils is free software; you can redistribute it and/or modify
6
+ # it under the terms of the MIT License; see LICENSE file for more details.
7
+
8
+ """Invenio App ILS circulation Loan Checkout loader JSON schema."""
9
+
10
+ from invenio_circulation.records.loaders.schemas.json import LoanItemPIDSchemaV1
11
+ from marshmallow import fields
12
+
13
+ from .base import LoanBaseSchemaV1
14
+
15
+
16
+ class LoanSelfCheckoutSchemaV1(LoanBaseSchemaV1):
17
+ """Loan self-checkout schema."""
18
+
19
+ item_pid = fields.Nested(LoanItemPIDSchemaV1, required=True)
@@ -25,6 +25,7 @@ class NotificationLoanMsg(NotificationMsg):
25
25
  request="request.html",
26
26
  request_no_items="request_no_items.html",
27
27
  checkout="checkout.html",
28
+ self_checkout="self_checkout.html",
28
29
  checkin="checkin.html",
29
30
  extend="extend.html",
30
31
  cancel="cancel.html",
@@ -6,6 +6,7 @@
6
6
  # under the terms of the MIT License; see LICENSE file for more details.
7
7
 
8
8
  """Loan serializers."""
9
+
9
10
  from invenio_records_rest.serializers.response import search_responsify
10
11
 
11
12
  from invenio_app_ils.records.schemas.json import ILSRecordSchemaJSONV1
@@ -6,6 +6,7 @@
6
6
  # under the terms of the MIT License; see LICENSE file for more details.
7
7
 
8
8
  """Response serializers for circulation module."""
9
+
9
10
  import json
10
11
 
11
12
  from flask import current_app
@@ -0,0 +1,19 @@
1
+ {% block title %}
2
+ InvenioILS: loan started for "{{ document.title|safe }}"
3
+ {% endblock %}
4
+
5
+ {% block body_plain %}
6
+ Dear {{ patron.name }},
7
+
8
+ your self-checkout loan for "{{ document.full_title }}" <{{ spa_routes.HOST }}{{ spa_routes.PATHS['literature']|format(pid=document.pid) }}> has started.
9
+
10
+ The due date is {{ loan.end_date }}.
11
+ {% endblock %}
12
+
13
+ {% block body_html %}
14
+ Dear {{ patron.name }}, <br/><br/>
15
+
16
+ your self-checkout loan for <a href="{{ spa_routes.HOST }}{{ spa_routes.PATHS['literature']|format(pid=document.pid) }}">"{{ document.full_title }}"</a> has <b>started</b>. <br/><br/>
17
+
18
+ <b>The due date is {{ loan.end_date }}</b>.<br/>
19
+ {% endblock %}
@@ -7,7 +7,7 @@
7
7
 
8
8
  """Invenio App ILS Circulation views."""
9
9
 
10
- from flask import Blueprint, abort
10
+ from flask import Blueprint, abort, request
11
11
  from flask_login import current_user
12
12
  from invenio_circulation.links import loan_links_factory
13
13
  from invenio_circulation.pidstore.pids import CIRCULATION_LOAN_PID_TYPE
@@ -18,15 +18,35 @@ from invenio_rest import ContentNegotiatedMethodView
18
18
  from invenio_app_ils.circulation.loaders import (
19
19
  loan_checkout_loader,
20
20
  loan_request_loader,
21
+ loan_self_checkout_loader,
21
22
  loan_update_dates_loader,
22
23
  loans_bulk_update_loader,
23
24
  )
24
25
  from invenio_app_ils.circulation.utils import circulation_overdue_loan_days
25
- from invenio_app_ils.errors import OverdueLoansNotificationError
26
+ from invenio_app_ils.errors import (
27
+ DocumentOverbookedError,
28
+ ItemCannotCirculateError,
29
+ ItemHasActiveLoanError,
30
+ ItemNotFoundError,
31
+ LoanSelfCheckoutDocumentOverbooked,
32
+ LoanSelfCheckoutItemActiveLoan,
33
+ LoanSelfCheckoutItemInvalidStatus,
34
+ LoanSelfCheckoutItemNotFound,
35
+ MissingRequiredParameterError,
36
+ OverdueLoansNotificationError,
37
+ )
38
+ from invenio_app_ils.items.api import ITEM_PID_TYPE
26
39
  from invenio_app_ils.permissions import need_permissions
27
40
 
28
41
  from ..patrons.api import patron_exists
29
- from .api import bulk_extend_loans, checkout_loan, request_loan, update_dates_loan
42
+ from .api import (
43
+ bulk_extend_loans,
44
+ checkout_loan,
45
+ request_loan,
46
+ self_checkout,
47
+ self_checkout_get_item_by_barcode,
48
+ update_dates_loan,
49
+ )
30
50
  from .notifications.api import (
31
51
  send_bulk_extend_notification,
32
52
  send_loan_overdue_reminder_notification,
@@ -43,73 +63,97 @@ def create_circulation_blueprint(app):
43
63
  url_prefix="",
44
64
  )
45
65
 
46
- endpoints = app.config.get("RECORDS_REST_ENDPOINTS", [])
47
- options = endpoints.get(CIRCULATION_LOAN_PID_TYPE, {})
66
+ options = app.config["RECORDS_REST_ENDPOINTS"][CIRCULATION_LOAN_PID_TYPE]
48
67
  default_media_type = options.get("default_media_type", "")
49
68
  rec_serializers = options.get("record_serializers", {})
50
69
  serializers = {
51
70
  mime: obj_or_import_string(func) for mime, func in rec_serializers.items()
52
71
  }
53
72
 
54
- bulk_loan_extension_serializers = {"application/json": bulk_extend_v1_response}
55
-
73
+ # /request
56
74
  loan_request = LoanRequestResource.as_view(
57
75
  LoanRequestResource.view_name,
58
76
  serializers=serializers,
59
77
  default_media_type=default_media_type,
60
78
  ctx=dict(links_factory=loan_links_factory, loader=loan_request_loader),
61
79
  )
62
-
63
80
  blueprint.add_url_rule(
64
81
  "/circulation/loans/request", view_func=loan_request, methods=["POST"]
65
82
  )
66
83
 
84
+ # /checkout
67
85
  loan_checkout = LoanCheckoutResource.as_view(
68
86
  LoanCheckoutResource.view_name,
69
87
  serializers=serializers,
70
88
  default_media_type=default_media_type,
71
89
  ctx=dict(links_factory=loan_links_factory, loader=loan_checkout_loader),
72
90
  )
73
-
74
91
  blueprint.add_url_rule(
75
92
  "/circulation/loans/checkout",
76
93
  view_func=loan_checkout,
77
94
  methods=["POST"],
78
95
  )
79
96
 
97
+ # /self-checkout
98
+ item_rec_serializers = app.config["RECORDS_REST_ENDPOINTS"][ITEM_PID_TYPE].get(
99
+ "record_serializers", {}
100
+ )
101
+ item_serializers = {
102
+ mime: obj_or_import_string(func) for mime, func in item_rec_serializers.items()
103
+ }
104
+ loan_self_checkout_item = LoanSelfCheckoutResource.as_view(
105
+ LoanSelfCheckoutResource.view_name,
106
+ serializers={}, # required, even if `method_serializers` will override it
107
+ method_serializers={
108
+ "GET": item_serializers,
109
+ "POST": serializers, # default loan serializers
110
+ },
111
+ default_media_type=default_media_type,
112
+ ctx=dict(
113
+ loan_links_factory=loan_links_factory, loader=loan_self_checkout_loader
114
+ ),
115
+ )
116
+ blueprint.add_url_rule(
117
+ "/circulation/loans/self-checkout",
118
+ view_func=loan_self_checkout_item,
119
+ methods=["GET", "POST"],
120
+ )
121
+
122
+ # /notification-overdue
80
123
  loan_notification_overdue = LoanNotificationResource.as_view(
81
124
  LoanNotificationResource.view_name.format(CIRCULATION_LOAN_PID_TYPE),
82
125
  serializers=serializers,
83
126
  default_media_type=default_media_type,
84
127
  ctx=dict(links_factory=loan_links_factory),
85
128
  )
86
-
87
129
  blueprint.add_url_rule(
88
130
  "{0}/notification-overdue".format(options["item_route"]),
89
131
  view_func=loan_notification_overdue,
90
132
  methods=["POST"],
91
133
  )
92
134
 
135
+ # /bulk-extend
136
+ bulk_loan_extension_serializers = {"application/json": bulk_extend_v1_response}
137
+
93
138
  bulk_loan_extension = BulkLoanExtensionResource.as_view(
94
139
  BulkLoanExtensionResource.view_name,
95
140
  serializers=bulk_loan_extension_serializers,
96
141
  default_media_type=default_media_type,
97
142
  ctx=dict(loader=loans_bulk_update_loader),
98
143
  )
99
-
100
144
  blueprint.add_url_rule(
101
145
  "/circulation/bulk-extend",
102
146
  view_func=bulk_loan_extension,
103
147
  methods=["POST"],
104
148
  )
105
149
 
150
+ # /update-dates
106
151
  loan_update = LoanUpdateDatesResource.as_view(
107
152
  LoanUpdateDatesResource.view_name.format(CIRCULATION_LOAN_PID_TYPE),
108
153
  serializers=serializers,
109
154
  default_media_type=default_media_type,
110
155
  ctx=dict(links_factory=loan_links_factory, loader=loan_update_dates_loader),
111
156
  )
112
-
113
157
  blueprint.add_url_rule(
114
158
  "{0}/update-dates".format(options["item_route"]),
115
159
  view_func=loan_update,
@@ -157,6 +201,50 @@ class LoanCheckoutResource(IlsCirculationResource):
157
201
  return self.make_response(pid, loan, 202, links_factory=self.links_factory)
158
202
 
159
203
 
204
+ class LoanSelfCheckoutResource(IlsCirculationResource):
205
+ """Loan self-checkout action resource."""
206
+
207
+ view_name = "loan_self_checkout"
208
+
209
+ @need_permissions("circulation-loan-self-checkout")
210
+ def get(self, **kwargs):
211
+ """Loan self-checkout GET method to retrieve items."""
212
+ try:
213
+ barcode = request.args["barcode"].upper()
214
+ assert barcode
215
+ except (KeyError, AssertionError):
216
+ msg = "Parameter `barcode` is missing"
217
+ raise MissingRequiredParameterError(description=msg)
218
+
219
+ try:
220
+ pid, item = self_checkout_get_item_by_barcode(barcode)
221
+ except ItemCannotCirculateError:
222
+ raise LoanSelfCheckoutItemInvalidStatus()
223
+ except ItemHasActiveLoanError:
224
+ raise LoanSelfCheckoutItemActiveLoan()
225
+ except DocumentOverbookedError:
226
+ raise LoanSelfCheckoutDocumentOverbooked()
227
+ except ItemNotFoundError:
228
+ raise LoanSelfCheckoutItemNotFound()
229
+
230
+ return self.make_response(pid, item, 200)
231
+
232
+ @need_permissions("circulation-loan-self-checkout")
233
+ def post(self, **kwargs):
234
+ """Loan self-checkout POST method to perform the checkout."""
235
+ data = self.loader()
236
+ try:
237
+ pid, loan = self_checkout(**data)
238
+ except ItemCannotCirculateError:
239
+ raise LoanSelfCheckoutItemInvalidStatus()
240
+ except ItemHasActiveLoanError:
241
+ raise LoanSelfCheckoutItemActiveLoan()
242
+ except DocumentOverbookedError:
243
+ raise LoanSelfCheckoutDocumentOverbooked()
244
+
245
+ return self.make_response(pid, loan, 202, links_factory=self.loan_links_factory)
246
+
247
+
160
248
  class BulkLoanExtensionResource(IlsCirculationResource):
161
249
  """Bulk loan extension resource."""
162
250