• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rero / rero-ils / 30269105825

27 Jul 2026 01:11PM UTC coverage: 91.264% (-0.03%) from 91.292%
30269105825

Pull #4186

github

web-flow
Merge 237ac5265 into edbd95eec
Pull Request #4186: build: update dependencies

82 of 109 new or added lines in 29 files covered. (75.23%)

24238 of 26558 relevant lines covered (91.26%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.77
/rero_ils/modules/items/api/circulation.py
1
# SPDX-FileCopyrightText: Fondation RERO+
2
# SPDX-License-Identifier: AGPL-3.0-or-later
3

4
"""API for manipulating item circulation transactions."""
5

6
from contextlib import suppress
1✔
7
from copy import deepcopy
1✔
8
from datetime import UTC, datetime
1✔
9

10
from flask import current_app
1✔
11
from flask_babel import gettext as _
1✔
12
from invenio_circulation.api import get_loan_for_item
1✔
13
from invenio_circulation.errors import (
1✔
14
    ItemNotAvailableError,
15
    NoValidTransitionAvailableError,
16
)
17
from invenio_circulation.proxies import current_circulation
1✔
18
from invenio_circulation.search.api import (
1✔
19
    search_by_patron_item_or_document,
20
    search_by_pid,
21
)
22
from invenio_pidstore.errors import PersistentIdentifierError
1✔
23
from invenio_records_rest.utils import obj_or_import_string
1✔
24
from invenio_search import current_search
1✔
25

26
from rero_ils.modules.loans.logs.api import NoCirculationOperationLog
1✔
27
from rero_ils.modules.locations.api import LocationsSearch
1✔
28
from rero_ils.modules.patron_transactions.api import PatronTransactionsSearch
1✔
29

30
from ....filter import format_date_filter
1✔
31
from ...circ_policies.api import CircPolicy
1✔
32
from ...errors import NoCirculationAction
1✔
33
from ...item_types.api import ItemType
1✔
34
from ...libraries.api import Library
1✔
35
from ...libraries.exceptions import LibraryNeverOpen
1✔
36
from ...loans.api import (
1✔
37
    Loan,
38
    get_last_transaction_loc_for_item,
39
    get_request_by_item_pid_by_patron_pid,
40
)
41
from ...loans.models import LoanAction, LoanState
1✔
42
from ...locations.api import Location
1✔
43
from ...patrons.api import Patron
1✔
44
from ...utils import extracted_data_from_ref, sorted_pids
1✔
45
from ..decorators import (
1✔
46
    add_action_parameters_and_flush_indexes,
47
    check_operation_allowed,
48
)
49
from ..models import ItemCirculationAction, ItemIssueStatus, ItemStatus
1✔
50
from ..utils import item_pid_to_object
1✔
51
from .record import ItemRecord
1✔
52

53

54
class ItemCirculation(ItemRecord):
1✔
55
    """Item circulation class."""
56

57
    statuses = {
1✔
58
        LoanState.ITEM_ON_LOAN: "on_loan",
59
        LoanState.ITEM_AT_DESK: "at_desk",
60
        LoanState.ITEM_IN_TRANSIT_FOR_PICKUP: "in_transit",
61
        LoanState.ITEM_IN_TRANSIT_TO_HOUSE: "in_transit",
62
    }
63

64
    def change_status_commit_and_reindex(self):
1✔
65
        """Change item status after a successfull circulation action.
66

67
        Commits and reindex the item.
68
        This method is executed after every successfull circulation action.
69
        """
70
        current_search.flush_and_refresh(current_circulation.loan_search_cls.Meta.index)
1✔
71
        self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
72

73
    def prior_validate_actions(self, **kwargs):
1✔
74
        """Check if the validate action can be executed or not."""
75
        if loan_pid := kwargs.get("pid"):
1✔
76
            # no item validation is possible when an item has an active loan.
77
            states = self.get_loans_states_by_item_pid_exclude_loan_pid(self.pid, loan_pid)
1✔
78
            active_states = current_app.config["CIRCULATION_STATES_LOAN_ACTIVE"]
1✔
79
            if set(active_states).intersection(states):
1✔
80
                raise NoValidTransitionAvailableError()
1✔
81
        else:
82
            # no validation is possible when loan is not found/given
83
            message = _("No circulation action performed: validate impossible")
1✔
84
            transaction_location_pid = kwargs.get("transaction_location_pid")
1✔
85
            if not transaction_location_pid and (transaction_library_pid := kwargs.pop("transaction_library_pid")):
1✔
86
                lib = Library.get_record_by_pid(transaction_library_pid)
×
87
                transaction_location_pid = lib.get_transaction_location_pid()
×
88
            scan = {
1✔
89
                "item": self,
90
                "transaction_location_pid": transaction_location_pid,
91
                "note": message,
92
            }
93
            NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
94
            raise NoCirculationAction(message)
1✔
95

96
    def prior_extend_loan_actions(self, **kwargs):
1✔
97
        """Actions to execute before an extend_loan action."""
98
        loan_pid = kwargs.get("pid")
1✔
99
        checked_out = True  # we consider loan as checked-out
1✔
100
        if not loan_pid:
1✔
101
            loan = self.get_first_loan_by_state(LoanState.ITEM_ON_LOAN)
1✔
102
            if not loan:
1✔
103
                # item was not checked out
104
                checked_out = False
1✔
105
        else:
106
            loan = Loan.get_record_by_pid(loan_pid)
1✔
107

108
        pickup_location_pid = None
1✔
109
        if loan:
1✔
110
            pickup_location_pid = loan.get("pickup_location_pid")
1✔
111
            transaction_location_pid = loan.get("transaction_location_pid")
1✔
112
        else:
113
            transaction_location_pid = kwargs.get("transaction_location_pid")
1✔
114
            if not transaction_location_pid and (transaction_library_pid := kwargs.pop("transaction_library_pid")):
1✔
115
                lib = Library.get_record_by_pid(transaction_library_pid)
×
116
                transaction_location_pid = lib.get_transaction_location_pid()
×
117

118
        # Check extend is allowed
119
        #   It's not allowed to extend an item if it is not checked out
120
        #   or has some pending requests already placed on it
121
        have_request = LoanState.PENDING in self.get_loan_states_for_an_item()
1✔
122
        if not checked_out or have_request:
1✔
123
            message = _("No circulation action performed: extension impossible")
1✔
124
            scan = {
1✔
125
                "item": self,
126
                "transaction_location_pid": transaction_location_pid,
127
                "note": message,
128
            }
129
            if pickup_location_pid:
1✔
130
                scan["pickup_location_pid"] = pickup_location_pid
1✔
131
            NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
132
            raise NoCirculationAction(message)
1✔
133

134
        return loan, kwargs
1✔
135

136
    def prior_checkin_actions(self, **kwargs):
1✔
137
        """Actions to execute before a smart checkin."""
138
        # TODO: find a better way to manage the different cases here.
139
        loan = None
1✔
140
        states = self.get_loan_states_for_an_item()
1✔
141
        if not states:
1✔
142
            # CHECKIN_1_1: item on_shelf, no pending loans.
143
            self.checkin_item_on_shelf(**kwargs)
1✔
144
        elif LoanState.ITEM_AT_DESK not in states and LoanState.ITEM_ON_LOAN not in states:
1✔
145
            if LoanState.ITEM_IN_TRANSIT_FOR_PICKUP in states:
1✔
146
                # CHECKIN_4: item in_transit (IN_TRANSIT_FOR_PICKUP)
147
                loan, kwargs = self.checkin_item_in_transit_for_pickup(**kwargs)
1✔
148
            elif LoanState.ITEM_IN_TRANSIT_TO_HOUSE in states:
1✔
149
                # CHECKIN_5: item in_transit (IN_TRANSIT_TO_HOUSE)
150
                loan, kwargs = self.checkin_item_in_transit_to_house(states, **kwargs)
1✔
151
            elif LoanState.PENDING in states:
1✔
152
                # CHECKIN_1_2_1: item on_shelf, with pending loans.
153
                loan, kwargs = self.validate_item_first_pending_request(**kwargs)
1✔
154
        elif LoanState.ITEM_AT_DESK in states:
1✔
155
            # CHECKIN_2: item at_desk
156
            self.checkin_item_at_desk(**kwargs)
1✔
157
        else:
158
            # CHECKIN_3: item on_loan, will be checked-in normally.
159
            loan = self.get_first_loan_by_state(state=LoanState.ITEM_ON_LOAN)
1✔
160
        return loan, kwargs
1✔
161

162
    def complete_action_missing_params(self, item=None, checkin_loan=None, **kwargs):
1✔
163
        """Add the missing parameters before executing a circulation action."""
164
        # TODO: find a better way to code this part.
165
        if not checkin_loan:
1✔
166
            loan = None
1✔
167
            if loan_pid := kwargs.get("pid"):
1✔
168
                loan = Loan.get_record_by_pid(loan_pid)
1✔
169
            patron_pid = kwargs.get("patron_pid")
1✔
170
            if patron_pid and not loan:
1✔
171
                data = {
1✔
172
                    "item_pid": item_pid_to_object(item["pid"]),
173
                    "patron_pid": patron_pid,
174
                }
175
                data.setdefault("transaction_date", datetime.now(UTC).isoformat())
1✔
176
                loan = Loan.create(data, dbcommit=True, reindex=True)
1✔
177
            if not patron_pid and loan:
1✔
178
                kwargs.setdefault("patron_pid", loan.patron_pid)
1✔
179

180
            kwargs.setdefault("pid", loan.pid)
1✔
181
            kwargs.setdefault("patron_pid", patron_pid)
1✔
182
        else:
183
            kwargs["patron_pid"] = checkin_loan.get("patron_pid")
1✔
184
            kwargs["pid"] = checkin_loan["pid"]
1✔
185
            loan = checkin_loan
1✔
186

187
        kwargs["item_pid"] = item_pid_to_object(item["pid"])
1✔
188

189
        kwargs["transaction_date"] = datetime.now(UTC).isoformat()
1✔
190
        document_pid = extracted_data_from_ref(item.get("document"))
1✔
191
        kwargs.setdefault("document_pid", document_pid)
1✔
192
        # set the transaction location for the circulation transaction
193
        transaction_location_pid = kwargs.get("transaction_location_pid")
1✔
194
        if not transaction_location_pid and (transaction_library_pid := kwargs.pop("transaction_library_pid")):
1✔
195
            lib = Library.get_record_by_pid(transaction_library_pid)
1✔
196
            kwargs["transaction_location_pid"] = lib.get_transaction_location_pid()
1✔
197
        # set the pickup_location_pid field if not found for loans that are
198
        # ready for checkout.
199
        if not kwargs.get("pickup_location_pid") and loan.get("state") in [
1✔
200
            LoanState.CREATED,
201
            LoanState.ITEM_AT_DESK,
202
        ]:
203
            kwargs["pickup_location_pid"] = kwargs.get("transaction_location_pid")
1✔
204
        return loan, kwargs
1✔
205

206
    def checkin_item_on_shelf(self, **kwargs):
1✔
207
        """Checkin actions for an item on_shelf.
208

209
        :param item : the item record
210
        :param kwargs : all others named arguments
211
        """
212
        # CHECKIN_1_1: item on_shelf, no pending loans.
213
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
214
        transaction_item_libraries = libraries["transaction_item_libraries"]
1✔
215

216
        transaction_location_pid = kwargs.get("transaction_location_pid")
1✔
217
        if not transaction_location_pid and (transaction_library_pid := kwargs.pop("transaction_library_pid")):
1✔
218
            lib = Library.get_record_by_pid(transaction_library_pid)
1✔
219
            transaction_location_pid = lib.get_transaction_location_pid()
1✔
220
        scan = {
1✔
221
            "item": self,
222
            "transaction_location_pid": transaction_location_pid,
223
        }
224
        if transaction_item_libraries:
1✔
225
            # CHECKIN_1_1_1, item library = transaction library
226
            # item will be checked in in home library, no action
227
            if self.status != ItemStatus.ON_SHELF:
1✔
228
                self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
229
                message = _("No circulation action performed: item returned at owning library")
1✔
230
                scan["note"] = message
1✔
231
                NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
232
                raise NoCirculationAction(message)
1✔
233
            message = _("No circulation action performed: on shelf")
1✔
234
            scan["note"] = message
1✔
235
            NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
236
            raise NoCirculationAction(message)
1✔
237
        # CHECKIN_1_1_2: item library != transaction library
238
        # item will be checked-in in an external library, no
239
        # circulation action performed, add item status in_transit
240
        self["status"] = ItemStatus.IN_TRANSIT
1✔
241
        self.status_update(self, on_shelf=False, dbcommit=True, reindex=True, forceindex=True)
1✔
242
        message = _("No circulation action performed: in transit added")
1✔
243
        scan["note"] = message
1✔
244
        NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
245
        raise NoCirculationAction(message)
1✔
246

247
    def checkin_item_at_desk(self, **kwargs):
1✔
248
        """Checkin actions for at_desk item.
249

250
        :param item : the item record
251
        :param kwargs : all others named arguments
252
        """
253
        # CHECKIN_2: item at_desk
254
        at_desk_loan = self.get_first_loan_by_state(state=LoanState.ITEM_AT_DESK)
1✔
255
        kwargs["pickup_location_pid"] = at_desk_loan["pickup_location_pid"]
1✔
256
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
257
        if libraries["transaction_pickup_libraries"]:
1✔
258
            # CHECKIN_2_1: pickup location = transaction library
259
            # (no action, item is: at_desk (ITEM_AT_DESK))
260
            message = _("No circulation action performed: item at desk")
1✔
261
            scan = {
1✔
262
                "item": self,
263
                "pickup_location_pid": at_desk_loan.get("pickup_location_pid"),
264
                "transaction_location_pid": at_desk_loan.get("transaction_location_pid"),
265
                "note": message,
266
            }
267
            NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
268
            raise NoCirculationAction(message)
1✔
269
        # CHECKIN_2_2: pickup location != transaction library
270
        # item is: in_transit
271
        at_desk_loan["state"] = LoanState.ITEM_IN_TRANSIT_FOR_PICKUP
1✔
272
        at_desk_loan.update(at_desk_loan, dbcommit=True, reindex=True)
1✔
273
        self["status"] = ItemStatus.IN_TRANSIT
1✔
274
        self.status_update(self, on_shelf=False, dbcommit=True, reindex=True, forceindex=True)
1✔
275
        message = _("No circulation action performed: in transit added")
1✔
276
        scan = {
1✔
277
            "item": self,
278
            "pickup_location_pid": at_desk_loan.get("pickup_location_pid"),
279
            "transaction_location_pid": at_desk_loan.get("transaction_location_pid"),
280
            "note": message,
281
        }
282
        NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
283
        raise NoCirculationAction(message)
1✔
284

285
    def checkin_item_in_transit_for_pickup(self, **kwargs):
1✔
286
        """Checkin actions for item in_transit for pickup.
287

288
        :param item : the item record
289
        :param kwargs : all others named arguments
290
        """
291
        # CHECKIN_4: item in_transit (IN_TRANSIT_FOR_PICKUP)
292
        in_transit_loan = self.get_first_loan_by_state(state=LoanState.ITEM_IN_TRANSIT_FOR_PICKUP)
1✔
293
        kwargs["pickup_location_pid"] = in_transit_loan["pickup_location_pid"]
1✔
294
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
295
        if libraries["transaction_pickup_libraries"]:
1✔
296
            # CHECKIN_4_1: pickup location = transaction library
297
            # (delivery_receive current loan, item is: at_desk(ITEM_AT_DESK))
298
            kwargs["receive_in_transit_request"] = True
1✔
299
            return in_transit_loan, kwargs
1✔
300
        # CHECKIN_4_2: pickup location != transaction library
301
        # (no action, item is: in_transit (IN_TRANSIT_FOR_PICKUP))
302
        message = _("No circulation action performed: in transit added")
1✔
303
        scan = {
1✔
304
            "item": self,
305
            "pickup_location_pid": in_transit_loan.get("pickup_location_pid"),
306
            "transaction_location_pid": in_transit_loan.get("transaction_location_pid"),
307
            "note": message,
308
        }
309
        NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
310
        raise NoCirculationAction(message)
1✔
311

312
    def checkin_item_in_transit_to_house(self, loans_list, **kwargs):
1✔
313
        """Checkin actions for an item in IN_TRANSIT_TO_HOUSE with no requests.
314

315
        :param item : the item record
316
        :param loans_list: list of loans states attached to the item
317
        :param kwargs : all others named arguments
318
        """
319
        # CHECKIN_5: item in_transit (IN_TRANSIT_TO_HOUSE)
320
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
321
        transaction_item_libraries = libraries["transaction_item_libraries"]
1✔
322
        in_transit_loan = self.get_first_loan_by_state(state=LoanState.ITEM_IN_TRANSIT_TO_HOUSE)
1✔
323
        if LoanState.PENDING not in loans_list:
1✔
324
            # CHECKIN_5_1: item has no pending loans
325
            if not transaction_item_libraries:
1✔
326
                # CHECKIN_5_1_2: item location != transaction library
327
                # (no action, item is: in_transit (IN_TRANSIT_TO_HOUSE))
328
                message = _("No circulation action performed: in transit to house")
1✔
329
                transaction_location_pid = kwargs.get("transaction_location_pid")
1✔
330
                if not transaction_location_pid and (transaction_library_pid := kwargs.pop("transaction_library_pid")):
1✔
331
                    lib = Library.get_record_by_pid(transaction_library_pid)
×
332
                    transaction_location_pid = lib.get_transaction_location_pid()
×
333
                scan = {
1✔
334
                    "item": self,
335
                    "transaction_location_pid": transaction_location_pid,
336
                    "note": message,
337
                }
338
                NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
339
                raise NoCirculationAction(message)
1✔
340
            # CHECKIN_5_1_1: item location = transaction library
341
            # (house_receive current loan, item is: on_shelf)
342
            kwargs["receive_in_transit_request"] = True
1✔
343
            loan = in_transit_loan
1✔
344
        else:
345
            # CHECKIN_5_2: item has pending requests.
346
            loan, kwargs = self.checkin_item_in_transit_to_house_with_requests(in_transit_loan, **kwargs)
1✔
347
        return loan, kwargs
1✔
348

349
    def checkin_item_in_transit_to_house_with_requests(self, in_transit_loan, **kwargs):
1✔
350
        """Checkin actions for an item in IN_TRANSIT_TO_HOUSE with requests.
351

352
        :param item : the item record
353
        :param in_transit_loan: the in_transit loan attached to the item
354
        :param kwargs : all others named arguments
355
        """
356
        if pending := self.get_first_loan_by_state(state=LoanState.PENDING):
1✔
357
            pending_params = kwargs
1✔
358
            pending_params["pickup_location_pid"] = pending["pickup_location_pid"]
1✔
359
            libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
360
            if libraries["transaction_pickup_libraries"]:
1✔
361
                # CHECKIN_5_2_1_1: pickup location of first PENDING loan = item
362
                # library (house_receive current loan, item is: at_desk
363
                # [automatic validate first PENDING loan]
364
                if libraries["item_pickup_libraries"]:
1✔
365
                    kwargs["receive_current_and_validate_first"] = True
1✔
366
                else:
367
                    # CHECKIN_5_2_1_2: pickup location of first PENDING loan !=
368
                    # item library (cancel current loan, item is: at_desk
369
                    # automatic validate first PENDING loan
370
                    kwargs["cancel_current_and_receive_first"] = True
1✔
371
            else:
372
                # CHECKIN_5_2_2: pickup location of first PENDING loan !=
373
                # transaction library
374
                if libraries["item_pickup_libraries"]:
1✔
375
                    # CHECKIN_5_2_2_1: pickup location of first PENDING loan =
376
                    # item library (no action, item is: IN_TRANSIT)
377
                    message = _("No circulation action performed: in transit")
1✔
378
                    scan = {
1✔
379
                        "item": self,
380
                        "pickup_location_pid": in_transit_loan.get("pickup_location_pid"),
381
                        "transaction_location_pid": in_transit_loan.get("transaction_location_pid"),
382
                        "note": message,
383
                    }
384
                    NoCirculationOperationLog.create(scan=scan, index_refresh=True)
1✔
385
                    raise NoCirculationAction(message)
1✔
386
                # CHECKIN_5_2_2_2: pickup location of first PENDING loan !=
387
                # item library (checkin current loan, item is: in_transit)
388
                # [automatic cancel current, automatic validate first loan]
389
                kwargs["cancel_current_and_receive_first"] = True
1✔
390
        return in_transit_loan, kwargs
1✔
391

392
    def validate_item_first_pending_request(self, **kwargs):
1✔
393
        """Validate the first pending request for an item.
394

395
        :param item : the item record
396
        :param kwargs : all others named arguments
397
        """
398
        loan = None
1✔
399
        if pending := self.get_first_loan_by_state(state=LoanState.PENDING):
1✔
400
            # validate the first pending request.
401
            kwargs["validate_current_loan"] = True
1✔
402
            loan = pending
1✔
403
        return loan, kwargs
1✔
404

405
    def compare_item_pickup_transaction_libraries(self, **kwargs):
1✔
406
        """Compare item library, pickup and transaction libraries.
407

408
        :param kwargs : all others named arguments
409
        :return a dict comparison with the following boolean keys
410
            `transaction_item_libraries`: between transaction and item
411
            `transaction_pickup_libraries`: between transaction and pickup
412
            `item_pickup_libraries`: between item and pickup
413
        """
414
        trans_loc_pid = kwargs.pop("transaction_location_pid", None)
1✔
415
        trans_lib_pid = (
1✔
416
            kwargs.pop("transaction_library_pid", None) or Location.get_record_by_pid(trans_loc_pid).library_pid
417
        )
418

419
        pickup_loc_pid = kwargs.pop("pickup_location_pid", None)
1✔
420
        pickup_lib_pid = kwargs.pop("pickup_library_pid", None)
1✔
421
        if not pickup_lib_pid:
1✔
422
            if not pickup_loc_pid:
1✔
423
                pickup_lib_pid = trans_lib_pid
1✔
424
            else:
425
                pickup_lib_pid = Location.get_record_by_pid(pickup_loc_pid).library_pid
1✔
426

427
        return {
1✔
428
            "transaction_item_libraries": self.library_pid == trans_lib_pid,
429
            "transaction_pickup_libraries": pickup_lib_pid == trans_lib_pid,
430
            "item_pickup_libraries": self.library_pid == pickup_lib_pid,
431
        }
432

433
    @check_operation_allowed(ItemCirculationAction.CHECKOUT)
1✔
434
    @add_action_parameters_and_flush_indexes
1✔
435
    def checkout(self, current_loan, **kwargs):
1✔
436
        """Checkout item to the user."""
437
        action_params, actions = self.prior_checkout_actions(kwargs)
1✔
438
        loan = Loan.get_record_by_pid(action_params.get("pid"))
1✔
439
        current_loan = loan or Loan.create(action_params, dbcommit=True, reindex=True)
1✔
440
        old_state = current_loan.get("state")
1✔
441

442
        # If 'end_date' is specified, we need to check if the selected date is
443
        # not a closed date. If it's a closed date, then we need to update the
444
        # value to the next open day.
445
        if "end_date" in action_params:
1✔
446
            # circulation parameters are to calculate from transaction library.
447
            transaction_library_pid = (
1✔
448
                LocationsSearch().get_record_by_pid(kwargs.get("transaction_location_pid")).library.pid
449
            ) or self.library_pid
450
            library = Library.get_record_by_pid(transaction_library_pid)
1✔
451
            if not library.is_open(action_params["end_date"], True, date_only=True):
1✔
452
                # If library has no open dates, keep the default due date
453
                # to avoid circulation errors
454
                with suppress(LibraryNeverOpen):
1✔
455
                    new_end_date = library.next_open(action_params["end_date"])
1✔
456
                    new_end_date = new_end_date.astimezone().replace(microsecond=0).isoformat()
1✔
457
                    action_params["end_date"] = new_end_date
1✔
458
        # Call invenio_circulation for 'checkout' trigger
459
        loan = current_circulation.circulation.trigger(current_loan, **dict(action_params, trigger="checkout"))
1✔
460
        new_state = loan.get("state")
1✔
461
        if old_state == new_state:
1✔
462
            current_app.logger.error(
×
463
                f"Loan state has not changed after CHECKOUT: {loan.pid} state: {old_state} kwargs: {kwargs}"
464
            )
465
        actions.update({LoanAction.CHECKOUT: loan})
1✔
466
        return self, actions
1✔
467

468
    @add_action_parameters_and_flush_indexes
1✔
469
    def cancel_loan(self, current_loan, **kwargs):
1✔
470
        """Cancel a given item loan for a patron."""
471
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="cancel"))
1✔
472
        return self, {LoanAction.CANCEL: loan}
1✔
473

474
    def cancel_item_request(self, pid, **kwargs):
1✔
475
        """A smart cancel request for an item. Some actions are performed.
476

477
        during the cancelling process and according to the loan state.
478
        :param pid: the loan pid for the request to cancel.
479
        :return: the item record and list of actions performed.
480
        """
481
        actions = {}
1✔
482
        loan = Loan.get_record_by_pid(pid)
1✔
483
        # decide  which actions need to be executed according to loan state.
484
        actions_to_execute = self.checks_before_a_cancel_item_request(loan, **kwargs)
1✔
485
        # execute the actions
486
        if actions_to_execute.get("cancel_loan"):
1✔
487
            item, actions = self.cancel_loan(pid=loan.pid, **kwargs)
1✔
488
        if actions_to_execute.get("loan_update", {}).get("state"):
1✔
489
            loan["state"] = actions_to_execute["loan_update"]["state"]
1✔
490
            loan.update(loan, dbcommit=True, reindex=True)
1✔
491
            self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
492
            actions.update({LoanAction.UPDATE: loan})
1✔
493
        elif actions_to_execute.get("validate_first_pending"):
1✔
494
            pending = self.get_first_loan_by_state(state=LoanState.PENDING)
1✔
495
            loan_pickup = loan.get("pickup_location_pid", None)
1✔
496
            pending_pickup = pending.get("pickup_location_pid", None)
1✔
497
            # If the item is at_desk at the same location as the next loan
498
            # pickup we can validate the next loan so that it becomes at desk
499
            # for the next patron.
500
            if loan.get("state") == LoanState.ITEM_AT_DESK and loan_pickup == pending_pickup:
1✔
501
                item, actions = self.cancel_loan(pid=loan.pid, **kwargs)
1✔
502
                kwargs["transaction_location_pid"] = loan_pickup
1✔
503
                kwargs.pop("transaction_library_pid", None)
1✔
504
                item, validate_actions = self.validate_request(pid=pending.pid, **kwargs)
1✔
505
                actions.update(validate_actions)
1✔
506
            # Otherwise, we simply change the state of the next loan and it
507
            # will be validated at the next checkin at the pickup location.
508
            else:
509
                pending["state"] = LoanState.ITEM_IN_TRANSIT_FOR_PICKUP
1✔
510
                pending.update(pending, dbcommit=True, reindex=True)
1✔
511
                item, actions = self.cancel_loan(pid=loan.pid, **kwargs)
1✔
512
                self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
513
                actions.update({LoanAction.UPDATE: loan})
1✔
514
        item = self
1✔
515
        return item, actions
1✔
516

517
    def checks_before_a_cancel_item_request(self, loan, **kwargs):
1✔
518
        """Actions tobe executed before a cancel item request.
519

520
        :param loan : the current loan to cancel
521
        :param kwargs : all others named arguments
522
        :return: the item record and list of actions performed
523
        """
524
        actions_to_execute = {
1✔
525
            "cancel_loan": False,
526
            "loan_update": {},
527
            "validate_first_pending": False,
528
        }
529
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
530
        # List all loan states attached to this item except the loan to cancel.
531
        # If the list is empty, no pending request/loan are linked to this item
532
        states = self.get_loans_states_by_item_pid_exclude_loan_pid(self.pid, loan.pid)
1✔
533
        if not states:
1✔
534
            if loan["state"] in [LoanState.PENDING, LoanState.ITEM_IN_TRANSIT_TO_HOUSE]:
1✔
535
                # CANCEL_REQUEST_1_2, CANCEL_REQUEST_5_1_1:
536
                # cancel the current loan is the only action
537
                actions_to_execute["cancel_loan"] = True
1✔
538
            elif loan["state"] == LoanState.ITEM_ON_LOAN:
1✔
539
                # CANCEL_REQUEST_3_1: no cancel action is possible on the loan
540
                # of a CHECKED_IN item.
541
                raise NoCirculationAction(_("No circulation action performed: item on loan"))
1✔
542
            elif loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP:
1✔
543
                # CANCEL_REQUEST_4_1_1: cancelling a ITEM_IN_TRANSIT_FOR_PICKUP
544
                # loan with no pending request puts the item on in_transit
545
                # and the loan becomes ITEM_IN_TRANSIT_TO_HOUSE.
546
                actions_to_execute["loan_update"]["state"] = LoanState.ITEM_IN_TRANSIT_TO_HOUSE
1✔
547
                # Mark the loan to be cancelled to create an
548
                # OperationLog about this cancellation.
549
                actions_to_execute["cancel_loan"] = True
1✔
550
            elif loan["state"] == LoanState.ITEM_AT_DESK:
1✔
551
                if not libraries["item_pickup_libraries"]:
1✔
552
                    # CANCEL_REQUEST_2_1_1_1: when item library and pickup
553
                    # pickup library arent equal, update loan to go in_transit.
554
                    actions_to_execute["loan_update"]["state"] = LoanState.ITEM_IN_TRANSIT_TO_HOUSE
1✔
555
                # Always mark the loan to be cancelled to create an
556
                # OperationLog about this cancellation.
557
                actions_to_execute["cancel_loan"] = True
1✔
558
        elif loan["state"] == LoanState.ITEM_AT_DESK and LoanState.PENDING in states:
1✔
559
            # CANCEL_REQUEST_2_1_2: when item at desk with pending loan, cancel
560
            # the loan triggers an automatic validation of first pending loan.
561
            actions_to_execute["validate_first_pending"] = True
1✔
562
        elif loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP and LoanState.PENDING in states:
1✔
563
            # CANCEL_REQUEST_4_1_2: when item in_transit with pending loan,
564
            # cancel the loan triggers an automatic validation of 1st loan.
565
            actions_to_execute["validate_first_pending"] = True
1✔
566
        elif loan["state"] == LoanState.ITEM_IN_TRANSIT_TO_HOUSE and LoanState.PENDING in states:
1✔
567
            # CANCEL_REQUEST_5_1_2: when item in_transit with pending loan,
568
            # cancelling the loan triggers an automatic validation of first
569
            # pending loan.
570
            actions_to_execute["validate_first_pending"] = True
1✔
571
        elif loan["state"] == LoanState.PENDING and any(
1✔
572
            state in states
573
            for state in [
574
                LoanState.ITEM_AT_DESK,
575
                LoanState.ITEM_ON_LOAN,
576
                LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
577
                LoanState.ITEM_IN_TRANSIT_TO_HOUSE,
578
                LoanState.PENDING,
579
            ]
580
        ):
581
            # CANCEL_REQUEST_1_2, CANCEL_REQUEST_2_2, CANCEL_REQUEST_3_2,
582
            # CANCEL_REQUEST_4_2 CANCEL_REQUEST_5_2:
583
            # canceling a pending loan does not affect the other active loans.
584
            actions_to_execute["cancel_loan"] = True
1✔
585

586
        return actions_to_execute
1✔
587

588
    @add_action_parameters_and_flush_indexes
1✔
589
    def validate_request(self, current_loan, **kwargs):
1✔
590
        """Validate item request."""
591
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="validate_request"))
1✔
592
        return self, {LoanAction.VALIDATE: loan}
1✔
593

594
    @add_action_parameters_and_flush_indexes
1✔
595
    @check_operation_allowed(ItemCirculationAction.EXTEND)
1✔
596
    def extend_loan(self, current_loan, **kwargs):
1✔
597
        """Extend checkout duration for this item."""
598
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="extend"))
1✔
599
        return self, {LoanAction.EXTEND: loan}
1✔
600

601
    @check_operation_allowed(ItemCirculationAction.REQUEST)
1✔
602
    @add_action_parameters_and_flush_indexes
1✔
603
    def request(self, current_loan, **kwargs):
1✔
604
        """Request item for the user and create notifications."""
605
        old_state = current_loan.get("state")
1✔
606
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="request"))
1✔
607
        new_state = loan.get("state")
1✔
608
        if old_state == new_state:
1✔
609
            current_app.logger.error(
×
610
                f"Loan state has not changed after REQUEST: {loan.pid} state: {old_state} kwargs: {kwargs}"
611
            )
612
        return self, {LoanAction.REQUEST: loan}
1✔
613

614
    @add_action_parameters_and_flush_indexes
1✔
615
    def receive(self, current_loan, **kwargs):
1✔
616
        """Receive an item."""
617
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="receive"))
1✔
618
        return self, {LoanAction.RECEIVE: loan}
1✔
619

620
    def checkin_triggers_validate_current_loan(self, actions, **kwargs):
1✔
621
        """Validate the current loan.
622

623
        :param actions : dict the list of actions performed
624
        :param kwargs : all others named arguments
625
        :return:  the item record and list of actions performed
626
        """
627
        if kwargs.pop("validate_current_loan", None):
1✔
628
            item, validate_actions = self.validate_request(**kwargs)
1✔
629
            actions = {LoanAction.VALIDATE: validate_actions}
1✔
630
            actions |= validate_actions
1✔
631
            return item, actions
1✔
632
        return self, actions
1✔
633

634
    def actions_after_a_checkin(self, checkin_loan, actions, **kwargs):
1✔
635
        """Actions executed after a checkin.
636

637
        :param checkin_loan : the checked-in loan
638
        :param actions : dict the list of actions performed
639
        :param kwargs : all others named arguments
640
        :return:  the item record and list of actions performed
641
        """
642
        if not self.number_of_requests():
1✔
643
            return self, actions
1✔
644
        request = next(self.get_requests())
1✔
645
        if checkin_loan.is_active:
1✔
646
            params = kwargs
1✔
647
            params["pid"] = checkin_loan.pid
1✔
648
            item, cancel_actions = self.cancel_loan(**params)
1✔
649
            actions.update(cancel_actions)
1✔
650
        # pass the correct transaction location
651
        transaction_loc_pid = checkin_loan.get("transaction_location_pid")
1✔
652
        request["transaction_location_pid"] = transaction_loc_pid
1✔
653
        # validate the request
654
        item, validate_actions = self.validate_request(**request)
1✔
655
        actions.update(validate_actions)
1✔
656
        validate_loan = validate_actions[LoanAction.VALIDATE]
1✔
657
        # receive the request if it is requested at transaction library
658
        if validate_loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP:
1✔
659
            trans_loc = Location.get_record_by_pid(transaction_loc_pid)
1✔
660
            req_loc = Location.get_record_by_pid(request.get("pickup_location_pid"))
1✔
661
            if req_loc.library_pid == trans_loc.library_pid:
1✔
662
                item, receive_action = self.receive(**request)
×
663
                actions.update(receive_action)
×
664
        return item, actions
1✔
665

666
    def checkin_triggers_receive_in_transit_current_loan(self, actions, **kwargs):
1✔
667
        """Receive the item_in_transit_for_pickup loan.
668

669
        :param actions : dict the list of actions performed
670
        :param kwargs : all others named arguments
671
        :return: the item record and list of actions performed
672
        """
673
        if kwargs.pop("receive_in_transit_request", None):
1✔
674
            item, receive_action = self.receive(**kwargs)
1✔
675
            actions.update(receive_action)
1✔
676
            # receive_loan = receive_action[LoanAction.RECEIVE]
677
            return item, actions
1✔
678
        return self, actions
1✔
679

680
    def checkin_triggers_receive_and_validate_requests(self, actions, **kwargs):
1✔
681
        """Receive the item_in_transit_in_house and validate first loan.
682

683
        :param actions : dict the list of actions performed
684
        :param kwargs : all others named arguments
685
        :return: the item record and list of actions performed
686
        """
687
        if not kwargs.pop("receive_current_and_validate_first", None):
1✔
688
            return self, actions
1✔
689
        item, receive_action = self.receive(**kwargs)
1✔
690
        actions.update(receive_action)
1✔
691
        receive_loan = receive_action[LoanAction.RECEIVE]
1✔
692
        if item.number_of_requests():
1✔
693
            request = next(item.get_requests())
1✔
694
            if receive_loan.is_active:
1✔
695
                params = kwargs
×
696
                params["pid"] = receive_loan.pid
×
697
                item, cancel_actions = item.cancel_loan(**params)
×
698
                actions.update(cancel_actions)
×
699
            # pass the correct transaction location
700
            transaction_loc_pid = receive_loan.get("transaction_location_pid")
1✔
701
            request["transaction_location_pid"] = transaction_loc_pid
1✔
702
            # validate the request
703
            item, validate_actions = item.validate_request(**request)
1✔
704
            actions.update(validate_actions)
1✔
705
            validate_loan = validate_actions[LoanAction.VALIDATE]
1✔
706
            # receive request if it is requested at transaction library
707
            if validate_loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP:
1✔
708
                trans_loc = Location.get_record_by_pid(transaction_loc_pid)
×
709
                req_loc = Location.get_record_by_pid(request.get("pickup_location_pid"))
×
710
                if req_loc.library_pid == trans_loc.library_pid:
×
711
                    item, receive_action = item.receive(**request)
×
712
                    actions.update(receive_action)
×
713
        return item, actions
1✔
714

715
    def checkin_triggers_cancel_and_receive_first_loan(self, current_loan, actions, **kwargs):
1✔
716
        """Cancel the current loan and receive the first request.
717

718
        :param current_loan : loan to cancel
719
        :param actions : dict the list of actions performed
720
        :param kwargs : all others named arguments
721
        :return: the item record and list of actions performed
722
        """
723
        if not kwargs.pop("cancel_current_and_receive_first", None):
1✔
724
            return self, actions
1✔
725
        params = kwargs
1✔
726
        params["pid"] = current_loan.pid
1✔
727
        item, cancel_actions = self.cancel_loan(**params)
1✔
728
        actions.update(cancel_actions)
1✔
729
        cancel_loan = cancel_actions[LoanAction.CANCEL]
1✔
730
        if item.number_of_requests():
1✔
731
            request = next(item.get_requests())
1✔
732
            # pass the correct transaction location
733
            transaction_loc_pid = cancel_loan.get("transaction_location_pid")
1✔
734
            request["transaction_location_pid"] = transaction_loc_pid
1✔
735
            # validate the request
736
            item, validate_actions = item.validate_request(**request)
1✔
737
            actions.update(validate_actions)
1✔
738
            validate_loan = validate_actions[LoanAction.VALIDATE]
1✔
739
            # receive request if it is requested at transaction library
740
            if validate_loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP:
1✔
741
                trans_loc = Location.get_record_by_pid(transaction_loc_pid)
1✔
742
                req_loc = Location.get_record_by_pid(request.get("pickup_location_pid"))
1✔
743
                if req_loc.library_pid == trans_loc.library_pid:
1✔
744
                    item, receive_action = item.receive(**request)
×
745
                    actions.update(receive_action)
×
746
        return item, actions
1✔
747

748
    @add_action_parameters_and_flush_indexes
1✔
749
    def checkin(self, current_loan, **kwargs):
1✔
750
        """Perform a smart checkin action."""
751
        actions = {}
1✔
752
        # checkin actions for an item on_shelf
753
        item, actions = self.checkin_triggers_validate_current_loan(actions, **kwargs)
1✔
754
        if actions:
1✔
755
            return item, actions
1✔
756
        # checkin actions for an item in_transit with no requests
757
        item, actions = self.checkin_triggers_receive_in_transit_current_loan(actions, **kwargs)
1✔
758
        if actions:
1✔
759
            return item, actions
1✔
760
        # checkin actions for an item in_transit_to_house at home library
761
        item, actions = self.checkin_triggers_receive_and_validate_requests(actions, **kwargs)
1✔
762
        if actions:
1✔
763
            return item, actions
1✔
764
        # checkin actions for an item in_transit_to_house at external library
765
        item, actions = self.checkin_triggers_cancel_and_receive_first_loan(current_loan, actions, **kwargs)
1✔
766
        if actions:
1✔
767
            return item, actions
1✔
768
        # standard checkin actions
769
        checkin_loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="checkin"))
1✔
770
        actions = {LoanAction.CHECKIN: checkin_loan}
1✔
771
        # validate and receive actions to execute after a standard checkin
772
        item, actions = self.actions_after_a_checkin(checkin_loan, actions, **kwargs)
1✔
773
        return self, actions
1✔
774

775
    def prior_checkout_actions(self, action_params):
1✔
776
        """Actions executed prior to a checkout."""
777
        actions = {}
1✔
778
        if action_params.get("pid"):
1✔
779
            loan = Loan.get_record_by_pid(action_params.get("pid"))
1✔
780
            if loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP and loan.get("patron_pid") == action_params.get(
1✔
781
                "patron_pid"
782
            ):
783
                _, receive_actions = self.receive(**action_params)
1✔
784
                actions |= receive_actions
1✔
785
            elif loan["state"] == LoanState.ITEM_IN_TRANSIT_TO_HOUSE:
1✔
786
                # do not pass the patron_pid when cancelling a loan
787
                cancel_params = deepcopy(action_params)
1✔
788
                cancel_params.pop("patron_pid")
1✔
789
                _, cancel_actions = self.cancel_loan(**cancel_params)
1✔
790
                actions.update(cancel_actions)
1✔
791
                del action_params["pid"]
1✔
792
                # TODO: Check what's wrong in this case because Loan is cancel
793
                # but loan variable is not updated and after prior_checkout
794
                # a checkout is done on the item (it becomes ON_LOAN)
795
        else:
796
            loan = get_loan_for_item(item_pid_to_object(self.pid))
×
797
            if loan and loan["state"] != LoanState.ITEM_AT_DESK:
×
798
                _, cancel_actions = self.cancel_loan(pid=loan.get("pid"))
×
799
                actions.update(cancel_actions)
×
800
        # CHECKOUT_1_2_2: checkout denied if some pending loan are linked to it
801
        # with different patrons
802
        # Except while coming from an ITEM_IN_TRANSIT_TO_HOUSE loan because
803
        # the loan is cancelled and then came up in ON_SHELF to be checkout
804
        # by the second patron.
805
        if self.status == ItemStatus.ON_SHELF and loan["state"] != LoanState.ITEM_IN_TRANSIT_TO_HOUSE:
1✔
806
            for res in self.get_item_loans_by_state(state=LoanState.PENDING):
1✔
807
                if res.patron_pid != loan.get("patron_pid"):
1✔
808
                    item_pid = item_pid_to_object(self.pid)
1✔
809
                    msg = f"A pending loan exists for patron {res.patron_pid}"
1✔
810
                    raise ItemNotAvailableError(item_pid=item_pid, description=msg)
1✔
811
                # exit from loop after evaluation of the first request.
812
                break
1✔
813
        return action_params, actions
1✔
814

815
    @classmethod
1✔
816
    def get_loans_by_item_pid(cls, item_pid):
1✔
817
        """Return any loan loans for item."""
818
        item_pid_object = item_pid_to_object(item_pid)
1✔
819
        results = (
1✔
820
            current_circulation.loan_search_cls()
821
            .filter("term", item_pid__value=item_pid_object["value"])
822
            .filter("term", item_pid__type=item_pid_object["type"])
823
            .source(includes="pid")
824
            .scan()
825
        )
826
        for loan in results:
1✔
827
            yield Loan.get_record_by_pid(loan.pid)
1✔
828

829
    @classmethod
1✔
830
    def get_loans_states_by_item_pid_exclude_loan_pid(cls, item_pid, loan_pid):
1✔
831
        """Return list of loan states for an item excluding a given loan.
832

833
        :param item_pid : the item pid
834
        :param loan_pid : the loan pid to exclude
835
        :return:  the list of loans states attached to the item
836
        """
837
        exclude_states = [
1✔
838
            LoanState.ITEM_RETURNED,
839
            LoanState.CANCELLED,
840
            LoanState.CREATED,
841
        ]
842
        item_pid_object = item_pid_to_object(item_pid)
1✔
843
        results = (
1✔
844
            current_circulation.loan_search_cls()
845
            .filter("term", item_pid__value=item_pid_object["value"])
846
            .filter("term", item_pid__type=item_pid_object["type"])
847
            .exclude("terms", state=exclude_states)
848
            .source(includes="pid")
849
            .scan()
850
        )
851
        return [Loan.get_record_by_pid(loan.pid)["state"] for loan in results if loan.pid != loan_pid]
1✔
852

853
    @classmethod
1✔
854
    def get_loan_pid_with_item_on_loan(cls, item_pid):
1✔
855
        """Returns loan pid for checked out item."""
856
        search = search_by_pid(
1✔
857
            item_pid=item_pid_to_object(item_pid),
858
            filter_states=[LoanState.ITEM_ON_LOAN],
859
        )
860
        results = search.source(["pid"]).scan()
1✔
861
        try:
1✔
862
            return next(results).pid
1✔
863
        except StopIteration:
1✔
864
            return None
1✔
865

866
    @classmethod
1✔
867
    def get_pendings_loans(cls, library_pid=None, sort_by="_created"):
1✔
868
        """Return list of sorted pending loans for a given library.
869

870
        default sort is set to _created
871
        """
872
        # check if library exists
873
        lib = Library.get_record_by_pid(library_pid)
×
874
        if not lib:
×
NEW
875
            raise ValueError("Invalid Library PID")
×
876
        # the '-' prefix means a desc order.
877
        sort_by = sort_by or "_created"
×
878
        order_by = "asc"
×
879
        if sort_by.startswith("-"):
×
880
            sort_by = sort_by[1:]
×
881
            order_by = "desc"
×
882

883
        results = (
×
884
            current_circulation.loan_search_cls()
885
            .params(preserve_order=True)
886
            .filter("term", state=LoanState.PENDING)
887
            .filter("term", library_pid=library_pid)
888
            .sort({sort_by: {"order": order_by}})
889
            .source(includes="pid")
890
            .scan()
891
        )
892
        for loan in results:
×
893
            yield Loan.get_record_by_pid(loan.pid)
×
894

895
    @classmethod
1✔
896
    def get_checked_out_loan_infos(cls, patron_pid, sort_by="_created"):
1✔
897
        """Returns sorted checked out loans for a given patron."""
898
        # the '-' prefix means a desc order.
899
        sort_by = sort_by or "_created"
1✔
900
        order_by = "asc"
1✔
901
        if sort_by.startswith("-"):
1✔
902
            sort_by = sort_by[1:]
1✔
903
            order_by = "desc"
1✔
904

905
        results = (
1✔
906
            search_by_patron_item_or_document(patron_pid=patron_pid, filter_states=[LoanState.ITEM_ON_LOAN])
907
            .params(preserve_order=True)
908
            .sort({sort_by: {"order": order_by}})
909
            .source(["pid", "item_pid.value"])
910
            .scan()
911
        )
912
        for data in results:
1✔
913
            yield data.pid, data.item_pid.value
1✔
914

915
    def get_last_location(self):
1✔
916
        """Returns the location record of the transaction location.
917

918
        of the last loan.
919
        """
920
        return Location.get_record_by_pid(self.last_location_pid)
1✔
921

922
    @property
1✔
923
    def last_location_pid(self):
1✔
924
        """Returns the location pid of the circulation transaction location.
925

926
        of the last loan.
927
        """
928
        loan_location_pid = get_last_transaction_loc_for_item(self.pid)
1✔
929
        if loan_location_pid and Location.get_record_by_pid(loan_location_pid):
1✔
930
            return loan_location_pid
×
931
        return self.location_pid
1✔
932

933
    def patron_has_an_active_loan_on_item(self, patron):
1✔
934
        """Return True if patron has an active loan on the item.
935

936
        The new circ specs do allow requests on ITEM_IN_TRANSIT_TO_HOUSE loans.
937

938
        :param patron_barcode: the patron barcode.
939
        :return: True is requested otherwise False.
940
        """
941
        if patron:
1✔
942
            search = (
1✔
943
                search_by_patron_item_or_document(
944
                    item_pid=item_pid_to_object(self.pid),
945
                    patron_pid=patron.pid,
946
                    filter_states=[
947
                        LoanState.PENDING,
948
                        LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
949
                        LoanState.ITEM_AT_DESK,
950
                        LoanState.ITEM_ON_LOAN,
951
                    ],
952
                )
953
                .params(preserve_order=True)
954
                .source(["state"])
955
            )
956
            return len(list(dict.fromkeys([result.state for result in search.scan()]))) > 0
1✔
957
        return None
×
958

959
    # CIRCULATION METHODS =====================================================
960
    def can(self, action, **kwargs):
1✔
961
        """Check if a specific action is allowed on this item.
962

963
        :param action : the action to check as ItemCirculationAction part
964
        :param kwargs : all others named arguments useful to check
965
        :return a tuple with True|False to know if the action is possible and
966
                a list of reasons to disallow if False.
967
        """
968
        can, reasons = True, {}
1✔
969
        actions = current_app.config.get("ITEM_CIRCULATION_ACTIONS_VALIDATION", {})
1✔
970
        for func_name in actions.get(action, []):
1✔
971
            func_callback = obj_or_import_string(func_name)
1✔
972
            func_can, func_reasons = func_callback(self, **kwargs)
1✔
973
            reasons.update(func_reasons)
1✔
974
            can = can and func_can
1✔
975
        return can, reasons
1✔
976

977
    @classmethod
1✔
978
    def can_request(cls, item, **kwargs):
1✔
979
        """Check if an item can be requested regarding the item status.
980

981
        :param item : the item to check.
982
        :param kwargs : other arguments.
983
        :return a tuple with True|False and reasons to disallow if False.
984
        """
985
        reasons = {}
1✔
986
        if item.status in [ItemStatus.MISSING, ItemStatus.EXCLUDED]:
1✔
987
            reasons["item_status"] = _("Item status disallows the operation.")
×
988
        patron = kwargs.get("patron")
1✔
989
        if not patron and "patron_pid" in kwargs:
1✔
990
            patron = Patron.get_record_by_pid(kwargs["patron_pid"])
1✔
991
        if patron:
1✔
992
            if patron.organisation_pid != item.organisation_pid:
1✔
993
                reasons["item_patron_organisation"] = _("Item and patron are not in the same organisation.")
×
994
            if patron.patron.get("barcode") and item.patron_has_an_active_loan_on_item(patron):
1✔
995
                reasons["item_already_checked_out_or_requested"] = _(
1✔
996
                    "Item is already checked-out or requested by patron."
997
                )
998
        return not reasons, reasons
1✔
999

1000
    def action_filter(self, action, organisation_pid, library_pid, loan, patron_pid, patron_type_pid):
1✔
1001
        """Filter actions."""
1002
        circ_policy = CircPolicy.provide_circ_policy(
1✔
1003
            organisation_pid,
1004
            library_pid,
1005
            patron_type_pid,
1006
            self.item_type_circulation_category_pid,
1007
        )
1008
        data = {"action_validated": True, "new_action": None}
1✔
1009
        if action == "extend":
1✔
1010
            can, _ = self.can(ItemCirculationAction.EXTEND, loan=loan)
1✔
1011
            if not can:
1✔
1012
                data["action_validated"] = False
1✔
1013
        if action == "checkout" and not circ_policy.can_checkout:
1✔
1014
            data["action_validated"] = False
×
1015
        elif (
1✔
1016
            action == "receive"
1017
            and circ_policy.can_checkout
1018
            and loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP
1019
            and loan.get("patron_pid") == patron_pid
1020
        ):
1021
            data["action_validated"] = False
1✔
1022
            data["new_action"] = "checkout"
1✔
1023
        return data
1✔
1024

1025
    @property
1✔
1026
    def actions(self):
1✔
1027
        """Get all available actions."""
1028
        transitions = current_app.config.get("CIRCULATION_LOAN_TRANSITIONS")
1✔
1029
        loan = get_loan_for_item(item_pid_to_object(self.pid))
1✔
1030
        actions = set()
1✔
1031
        if loan:
1✔
1032
            organisation_pid = self.organisation_pid
1✔
1033
            library_pid = self.library_pid
1✔
1034
            patron_pid = loan.get("patron_pid")
1✔
1035
            patron_type_pid = Patron.get_record_by_pid(patron_pid).patron_type_pid
1✔
1036
            for transition in transitions.get(loan["state"]):
1✔
1037
                action = transition.get("trigger")
1✔
1038
                data = self.action_filter(
1✔
1039
                    action=action,
1040
                    organisation_pid=organisation_pid,
1041
                    library_pid=library_pid,
1042
                    loan=loan,
1043
                    patron_pid=patron_pid,
1044
                    patron_type_pid=patron_type_pid,
1045
                )
1046

1047
                if data.get("action_validated"):
1✔
1048
                    actions.add(action)
1✔
1049
                if data.get("new_action"):
1✔
1050
                    actions.add(data.get("new_action"))
1✔
1051
        # default actions
1052

1053
        if not loan:
1✔
1054
            for transition in transitions.get(LoanState.CREATED):
1✔
1055
                action = transition.get("trigger")
1✔
1056
                actions.add(action)
1✔
1057
        # remove unsupported action
1058
        for action in ["cancel", "request"]:
1✔
1059
            with suppress(KeyError):
1✔
1060
                actions.remove(action)
1✔
1061
        # rename
1062
        with suppress(KeyError):
1✔
1063
            actions.remove("extend")
1✔
1064
            actions.add("extend_loan")
1✔
1065
        # if self['status'] == ItemStatus.MISSING:
1066
        #     actions.add('return_missing')
1067
        # else:
1068
        #     actions.add('lose')
1069
        return actions
1✔
1070

1071
    @classmethod
1✔
1072
    def status_update(cls, item, on_shelf=True, dbcommit=False, reindex=False, forceindex=False):
1✔
1073
        """Update item status.
1074

1075
        The item normally inherits its status from its active loan. In other
1076
        cases it goes back to on_shelf
1077

1078
        :param item: the item record
1079
        :param on_shelf: A boolean to indicate that item is candidate to go
1080
                         on_shelf
1081
        :param dbcommit: commit record to database
1082
        :param reindex: reindex record
1083
        :param forceindex: force the reindexation
1084
        """
1085
        if loan := get_loan_for_item(item_pid_to_object(item["pid"])):
1✔
1086
            item["status"] = cls.statuses[loan["state"]]
1✔
1087
        elif item["status"] != ItemStatus.MISSING and on_shelf:
1✔
1088
            item["status"] = ItemStatus.ON_SHELF
1✔
1089
        item.commit()
1✔
1090
        if dbcommit:
1✔
1091
            item.dbcommit(reindex=True, forceindex=True)
1✔
1092

1093
    def item_has_active_loan_or_request(self):
1✔
1094
        """Return True if active loan or a request found for item."""
1095
        states = [LoanState.PENDING] + current_app.config["CIRCULATION_STATES_LOAN_ACTIVE"]
1✔
1096
        search = search_by_pid(
1✔
1097
            item_pid=item_pid_to_object(self.pid),
1098
            filter_states=states,
1099
        )
1100
        return bool(search.count())
1✔
1101

1102
    def return_missing(self):
1✔
1103
        """Return the missing item.
1104

1105
        The item's status will be set to ItemStatus.ON_SHELF.
1106
        """
1107
        # TODO: check transaction location
1108
        self["status"] = ItemStatus.ON_SHELF
×
1109
        self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
×
1110
        return self, {LoanAction.RETURN_MISSING: None}
×
1111

1112
    def get_links_to_me(self, get_pids=False):
1✔
1113
        """Record links.
1114

1115
        :param get_pids: if True list of linked pids
1116
                         if False count of linked records
1117
        """
1118
        # avoid circular import
1119
        from rero_ils.modules.collections.api import CollectionsSearch
1✔
1120
        from rero_ils.modules.local_fields.api import LocalFieldsSearch
1✔
1121

1122
        query_loans = search_by_pid(
1✔
1123
            item_pid=item_pid_to_object(self.pid),
1124
            exclude_states=[
1125
                LoanState.CREATED,
1126
                LoanState.CANCELLED,
1127
                LoanState.ITEM_RETURNED,
1128
            ],
1129
        )
1130
        query_fees = (
1✔
1131
            PatronTransactionsSearch()
1132
            .filter("term", item__pid=self.pid)
1133
            .filter("term", status="open")
1134
            .filter("range", total_amount={"gt": 0})
1135
        )
1136
        query_collections = CollectionsSearch().filter("term", items__pid=self.pid)
1✔
1137
        query_local_fields = LocalFieldsSearch().get_local_fields(self.provider.pid_type, self.pid)
1✔
1138

1139
        if get_pids:
1✔
1140
            loans = sorted_pids(query_loans)
1✔
1141
            fees = sorted_pids(query_fees)
1✔
1142
            collections = sorted_pids(query_collections)
1✔
1143
            local_fields = sorted_pids(query_local_fields)
1✔
1144
        else:
1145
            loans = query_loans.count()
1✔
1146
            fees = query_fees.count()
1✔
1147
            collections = query_collections.count()
1✔
1148
            local_fields = query_local_fields.count()
1✔
1149
        links = {
1✔
1150
            "loans": loans,
1151
            "fees": fees,
1152
            "collections": collections,
1153
            "local_fields": local_fields,
1154
        }
1155
        return {k: v for k, v in links.items() if v}
1✔
1156

1157
    def get_requests(self, sort_by=None, output=None):
1✔
1158
        """Return sorted pending, item_on_transit, item_at_desk loans.
1159

1160
        :param sort_by: the sort to result. default sort is _created.
1161
        :param output: the type of output. 'pids', 'count' or 'obj' (default)
1162
        :return depending of output parameter:
1163
            - 'obj': a generator ``Loan`` objects.
1164
            - 'count': the request counter.
1165
            - 'pids': the request pids list
1166
        """
1167

1168
        def _list_obj():
1✔
1169
            order_by = "asc"
1✔
1170
            sort_term = sort_by or "_created"
1✔
1171
            if sort_term.startswith("-"):
1✔
1172
                (sort_term, order_by) = (sort_term[1:], "desc")
×
1173
            search_query = query.params(preserve_order=True).sort({sort_term: {"order": order_by}})
1✔
1174
            for result in search_query.scan():
1✔
1175
                yield Loan.get_record_by_pid(result.pid)
1✔
1176

1177
        query = search_by_pid(
1✔
1178
            item_pid=item_pid_to_object(self.pid),
1179
            filter_states=[
1180
                LoanState.PENDING,
1181
                LoanState.ITEM_AT_DESK,
1182
                LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
1183
            ],
1184
        ).source(["pid"])
1185
        if output == "pids":
1✔
1186
            return [hit.pid for hit in query.scan()]
1✔
1187
        return query.count() if output == "count" else _list_obj()
1✔
1188

1189
    def get_first_loan_by_state(self, state=None):
1✔
1190
        """Return the first loan with the given state and attached to item.
1191

1192
        :param state : the loan state
1193
        :return: first loan found otherwise None
1194
        """
1195
        try:
1✔
1196
            return next(self.get_item_loans_by_state(state=state))
1✔
1197
        except StopIteration:
1✔
1198
            return None
1✔
1199

1200
    def get_item_loans_by_state(self, state=None, sort_by=None):
1✔
1201
        """Return sorted item loans with a given state.
1202

1203
        default sort is _created.
1204
        :param state : the loan state
1205
        :param sort_by : field to use for sorting
1206
        :return: loans found
1207
        """
1208
        search = (
1✔
1209
            search_by_pid(item_pid=item_pid_to_object(self.pid), filter_states=[state])
1210
            .params(preserve_order=True)
1211
            .source(["pid"])
1212
        )
1213
        order_by = "asc"
1✔
1214
        sort_by = sort_by or "_created"
1✔
1215
        if sort_by.startswith("-"):
1✔
1216
            sort_by = sort_by[1:]
×
1217
            order_by = "desc"
×
1218
        search = search.sort({sort_by: {"order": order_by}})
1✔
1219
        for result in search.scan():
1✔
1220
            yield Loan.get_record_by_pid(result.pid)
1✔
1221

1222
    def get_loan_states_for_an_item(self):
1✔
1223
        """Return list of all the loan states attached to the item.
1224

1225
        :return: list of all loan states attached to the item
1226
        """
1227
        search = (
1✔
1228
            search_by_pid(
1229
                item_pid=item_pid_to_object(self.pid),
1230
                filter_states=[
1231
                    LoanState.PENDING,
1232
                    LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
1233
                    LoanState.ITEM_IN_TRANSIT_TO_HOUSE,
1234
                    LoanState.ITEM_AT_DESK,
1235
                    LoanState.ITEM_ON_LOAN,
1236
                ],
1237
            )
1238
            .params(preserve_order=True)
1239
            .source(["state"])
1240
        )
1241
        return list(dict.fromkeys([result.state for result in search.scan()]))
1✔
1242

1243
    def is_available(self):
1✔
1244
        """Get availability for item.
1245

1246
        Note: if the logic has to be changed here please check also for
1247
        documents and holdings availability.
1248
        """
1249
        from ..api import ItemsSearch
1✔
1250

1251
        items_query = ItemsSearch().available_query()
1✔
1252

1253
        # check item availability
1254
        if not items_query.filter("term", pid=self.pid).count():
1✔
1255
            return False
1✔
1256

1257
        # --------------- Loans -------------------
1258
        # unavailable if the current item has active loans
1259
        return not self.item_has_active_loan_or_request()
1✔
1260

1261
    @property
1✔
1262
    def availability_text(self):
1✔
1263
        """Availability text to display for an item."""
1264
        circ_category = self.circulation_category
1✔
1265
        if circ_category.get("negative_availability"):
1✔
1266
            return [
1✔
1267
                *circ_category.get("displayed_status", []),
1268
                {"language": "default", "label": circ_category.get("name")},
1269
            ]
1270
        label = self.status
1✔
1271
        if self.is_issue and self.issue_status != ItemIssueStatus.RECEIVED:
1✔
1272
            label = self.issue_status
1✔
1273
        return [{"language": "default", "label": label}]
1✔
1274

1275
    @property
1✔
1276
    def temp_item_type_negative_availability(self):
1✔
1277
        """Get the temporary item type neg availability."""
1278
        if self.get("temporary_item_type"):
1✔
1279
            return ItemType.get_record_by_pid(extracted_data_from_ref(self.get("temporary_item_type"))).get(
1✔
1280
                "negative_availability", False
1281
            )
1282
        return False
1✔
1283

1284
    def get_item_end_date(self, format="short", time_format="medium", language=None):
1✔
1285
        """Get item due date for a given item.
1286

1287
        :param format: The date format, ex: 'full', 'medium', 'short'
1288
                        or custom
1289
        :param time_format: The time format, ex: 'medium', 'short' or custom
1290
        :param language: The language to fix the language format
1291
        :return: original date, formatted date or None
1292
        """
1293
        if loan := get_loan_for_item(item_pid_to_object(self.pid)):
×
1294
            end_date = loan["end_date"]
×
1295
            if format:
×
1296
                return format_date_filter(
×
1297
                    end_date,
1298
                    date_format=format,
1299
                    time_format=time_format,
1300
                    locale=language,
1301
                )
1302
            return end_date
×
1303
        return None
×
1304

1305
    def get_extension_count(self):
1✔
1306
        """Get item renewal count."""
1307
        if loan := get_loan_for_item(item_pid_to_object(self.pid)):
1✔
1308
            return loan.get("extension_count", 0)
1✔
1309
        return 0
×
1310

1311
    def number_of_requests(self):
1✔
1312
        """Get number of requests for a given item."""
1313
        return self.get_requests(output="count")
1✔
1314

1315
    def patron_request_rank(self, patron):
1✔
1316
        """Get the rank of patron in list of requests on this item."""
1317
        if patron:
1✔
1318
            requests = self.get_requests()
1✔
1319
            for rank, request in enumerate(requests, start=1):
1✔
1320
                if request["patron_pid"] == patron.pid:
1✔
1321
                    return rank
1✔
1322
        return 0
×
1323

1324
    def is_requested_by_patron(self, patron_barcode):
1✔
1325
        """Check if the item is requested by a given patron."""
1326
        if patron := Patron.get_patron_by_barcode(barcode=patron_barcode, org_pid=self.organisation_pid):
1✔
1327
            if get_request_by_item_pid_by_patron_pid(item_pid=self.pid, patron_pid=patron.pid):
1✔
1328
                return True
1✔
1329
        return False
1✔
1330

1331
    @classmethod
1✔
1332
    def get_requests_to_validate(cls, library_pid=None, sort_by=None):
1✔
1333
        """Returns list of requests to validate for a given library."""
1334
        loans = cls.get_pendings_loans(library_pid=library_pid, sort_by=sort_by)
×
1335
        returned_item_pids = []
×
1336
        for loan in loans:
×
1337
            item_pid = loan.get("item_pid", {}).get("value")
×
1338
            item = cls.get_record_by_pid(item_pid)
×
1339
            if item.status == ItemStatus.ON_SHELF and item_pid not in returned_item_pids:
×
1340
                returned_item_pids.append(item_pid)
×
1341
                yield item, loan
×
1342

1343
    @staticmethod
1✔
1344
    def item_exists(item_pid):
1✔
1345
        """Returns true if item exists for the given item_pid.
1346

1347
        :param item_pid: the item_pid object
1348
        :type item_pid: object
1349
        :return: True if item found otherwise False
1350
        :rtype: bool
1351
        """
1352
        from .api import Item
1✔
1353

1354
        try:
1✔
1355
            Item.get_record_by_pid(item_pid.get("value"))
1✔
1356
        except PersistentIdentifierError:
×
1357
            return False
×
1358
        return True
1✔
1359

1360
    @classmethod
1✔
1361
    def get_checked_out_items(cls, patron_pid=None, sort_by=None):
1✔
1362
        """Return sorted checked out items for a given patron."""
1363
        from .api import Item
1✔
1364

1365
        loan_infos = cls.get_checked_out_loan_infos(patron_pid=patron_pid, sort_by=sort_by)
1✔
1366
        returned_item_pids = []
1✔
1367
        for loan_pid, item_pid in loan_infos:
1✔
1368
            if item_pid not in returned_item_pids:
1✔
1369
                returned_item_pids.append(item_pid)
1✔
1370
                yield Item.get_record_by_pid(item_pid)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc