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

rero / rero-ils / 18465237290

13 Oct 2025 12:07PM UTC coverage: 91.939% (+0.02%) from 91.924%
18465237290

Pull #3962

github

web-flow
Merge 03337149f into ca0a880fa
Pull Request #3962: operation log: create a scan_item operation_log

85 of 86 new or added lines in 2 files covered. (98.84%)

55 existing lines in 1 file now uncovered.

23427 of 25481 relevant lines covered (91.94%)

0.92 hits per line

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

91.83
/rero_ils/modules/items/api/circulation.py
1
# -*- coding: utf-8 -*-
2
#
3
# RERO ILS
4
# Copyright (C) 2019-2022 RERO
5
#
6
# This program is free software: you can redistribute it and/or modify
7
# it under the terms of the GNU Affero General Public License as published by
8
# the Free Software Foundation, version 3 of the License.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU Affero General Public License for more details.
14
#
15
# You should have received a copy of the GNU Affero General Public License
16
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17

18
"""API for manipulating item circulation transactions."""
19

20
from contextlib import suppress
1✔
21
from copy import deepcopy
1✔
22
from datetime import datetime
1✔
23

24
from flask import current_app
1✔
25
from flask_babel import gettext as _
1✔
26
from invenio_circulation.api import get_loan_for_item
1✔
27
from invenio_circulation.errors import (
1✔
28
    ItemNotAvailableError,
29
    NoValidTransitionAvailableError,
30
)
31
from invenio_circulation.proxies import current_circulation
1✔
32
from invenio_circulation.search.api import (
1✔
33
    search_by_patron_item_or_document,
34
    search_by_pid,
35
)
36
from invenio_pidstore.errors import PersistentIdentifierError
1✔
37
from invenio_records_rest.utils import obj_or_import_string
1✔
38
from invenio_search import current_search
1✔
39

40
from rero_ils.modules.loans.logs.api import NoCirculationOperationLog
1✔
41
from rero_ils.modules.locations.api import LocationsSearch
1✔
42
from rero_ils.modules.patron_transactions.api import PatronTransactionsSearch
1✔
43

44
from ....filter import format_date_filter
1✔
45
from ...circ_policies.api import CircPolicy
1✔
46
from ...errors import NoCirculationAction
1✔
47
from ...item_types.api import ItemType
1✔
48
from ...libraries.api import Library
1✔
49
from ...libraries.exceptions import LibraryNeverOpen
1✔
50
from ...loans.api import (
1✔
51
    Loan,
52
    get_last_transaction_loc_for_item,
53
    get_request_by_item_pid_by_patron_pid,
54
)
55
from ...loans.models import LoanAction, LoanState
1✔
56
from ...locations.api import Location
1✔
57
from ...patrons.api import Patron
1✔
58
from ...utils import extracted_data_from_ref, sorted_pids
1✔
59
from ..decorators import (
1✔
60
    add_action_parameters_and_flush_indexes,
61
    check_operation_allowed,
62
)
63
from ..models import ItemCirculationAction, ItemIssueStatus, ItemStatus
1✔
64
from ..utils import item_pid_to_object
1✔
65
from .record import ItemRecord
1✔
66

67

68
class ItemCirculation(ItemRecord):
1✔
69
    """Item circulation class."""
70

71
    statuses = {
1✔
72
        LoanState.ITEM_ON_LOAN: "on_loan",
73
        LoanState.ITEM_AT_DESK: "at_desk",
74
        LoanState.ITEM_IN_TRANSIT_FOR_PICKUP: "in_transit",
75
        LoanState.ITEM_IN_TRANSIT_TO_HOUSE: "in_transit",
76
    }
77

78
    def change_status_commit_and_reindex(self):
1✔
79
        """Change item status after a successfull circulation action.
80

81
        Commits and reindex the item.
82
        This method is executed after every successfull circulation action.
83
        """
84
        current_search.flush_and_refresh(current_circulation.loan_search_cls.Meta.index)
1✔
85
        self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
86

87
    def prior_validate_actions(self, **kwargs):
1✔
88
        """Check if the validate action can be executed or not."""
89
        if loan_pid := kwargs.get("pid"):
1✔
90
            # no item validation is possible when an item has an active loan.
91
            states = self.get_loans_states_by_item_pid_exclude_loan_pid(self.pid, loan_pid)
1✔
92
            active_states = current_app.config["CIRCULATION_STATES_LOAN_ACTIVE"]
1✔
93
            if set(active_states).intersection(states):
1✔
94
                raise NoValidTransitionAvailableError()
1✔
95
        else:
96
            # no validation is possible when loan is not found/given
97
            message = _("No circulation action performed: validate impossible")
1✔
98
            NoCirculationOperationLog.create(item=self, message=message, index_refresh=True)
1✔
99
            raise NoCirculationAction(message)
1✔
100

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

113
        # Check extend is allowed
114
        #   It's not allowed to extend an item if it is not checked out
115
        #   or has some pending requests already placed on it
116
        have_request = LoanState.PENDING in self.get_loan_states_for_an_item()
1✔
117
        if not checked_out or have_request:
1✔
118
            message = _("No circulation action performed: extension impossible")
1✔
119
            NoCirculationOperationLog.create(item=self, loan=loan, message=message, index_refresh=True)
1✔
120
            raise NoCirculationAction(message)
1✔
121

122
        return loan, kwargs
1✔
123

124
    def prior_checkin_actions(self, **kwargs):
1✔
125
        """Actions to execute before a smart checkin."""
126
        # TODO: find a better way to manage the different cases here.
127
        loan = None
1✔
128
        states = self.get_loan_states_for_an_item()
1✔
129
        if not states:
1✔
130
            # CHECKIN_1_1: item on_shelf, no pending loans.
131
            self.checkin_item_on_shelf(**kwargs)
1✔
132
        elif LoanState.ITEM_AT_DESK not in states and LoanState.ITEM_ON_LOAN not in states:
1✔
133
            if LoanState.ITEM_IN_TRANSIT_FOR_PICKUP in states:
1✔
134
                # CHECKIN_4: item in_transit (IN_TRANSIT_FOR_PICKUP)
135
                loan, kwargs = self.checkin_item_in_transit_for_pickup(**kwargs)
1✔
136
            elif LoanState.ITEM_IN_TRANSIT_TO_HOUSE in states:
1✔
137
                # CHECKIN_5: item in_transit (IN_TRANSIT_TO_HOUSE)
138
                loan, kwargs = self.checkin_item_in_transit_to_house(states, **kwargs)
1✔
139
            elif LoanState.PENDING in states:
1✔
140
                # CHECKIN_1_2_1: item on_shelf, with pending loans.
141
                loan, kwargs = self.validate_item_first_pending_request(**kwargs)
1✔
142
        elif LoanState.ITEM_AT_DESK in states:
1✔
143
            # CHECKIN_2: item at_desk
144
            self.checkin_item_at_desk(**kwargs)
1✔
145
        else:
146
            # CHECKIN_3: item on_loan, will be checked-in normally.
147
            loan = self.get_first_loan_by_state(state=LoanState.ITEM_ON_LOAN)
1✔
148
        return loan, kwargs
1✔
149

150
    def complete_action_missing_params(self, item=None, checkin_loan=None, **kwargs):
1✔
151
        """Add the missing parameters before executing a circulation action."""
152
        # TODO: find a better way to code this part.
153
        if not checkin_loan:
1✔
154
            loan = None
1✔
155
            if loan_pid := kwargs.get("pid"):
1✔
156
                loan = Loan.get_record_by_pid(loan_pid)
1✔
157
            patron_pid = kwargs.get("patron_pid")
1✔
158
            if patron_pid and not loan:
1✔
159
                data = {
1✔
160
                    "item_pid": item_pid_to_object(item.pid),
161
                    "patron_pid": patron_pid,
162
                }
163
                data.setdefault("transaction_date", datetime.utcnow().isoformat())
1✔
164
                loan = Loan.create(data, dbcommit=True, reindex=True)
1✔
165
            if not patron_pid and loan:
1✔
166
                kwargs.setdefault("patron_pid", loan.patron_pid)
1✔
167

168
            kwargs.setdefault("pid", loan.pid)
1✔
169
            kwargs.setdefault("patron_pid", patron_pid)
1✔
170
        else:
171
            kwargs["patron_pid"] = checkin_loan.get("patron_pid")
1✔
172
            kwargs["pid"] = checkin_loan.pid
1✔
173
            loan = checkin_loan
1✔
174

175
        kwargs["item_pid"] = item_pid_to_object(item.pid)
1✔
176

177
        kwargs["transaction_date"] = datetime.utcnow().isoformat()
1✔
178
        document_pid = extracted_data_from_ref(item.get("document"))
1✔
179
        kwargs.setdefault("document_pid", document_pid)
1✔
180
        # set the transaction location for the circulation transaction
181
        transaction_location_pid = kwargs.get("transaction_location_pid")
1✔
182
        if not transaction_location_pid:
1✔
183
            transaction_library_pid = kwargs.pop("transaction_library_pid", None)
1✔
184
            if transaction_library_pid is not None:
1✔
185
                lib = Library.get_record_by_pid(transaction_library_pid)
1✔
186
                kwargs["transaction_location_pid"] = lib.get_transaction_location_pid()
1✔
187
        # set the pickup_location_pid field if not found for loans that are
188
        # ready for checkout.
189
        if not kwargs.get("pickup_location_pid") and loan.get("state") in [
1✔
190
            LoanState.CREATED,
191
            LoanState.ITEM_AT_DESK,
192
        ]:
193
            kwargs["pickup_location_pid"] = kwargs.get("transaction_location_pid")
1✔
194
        return loan, kwargs
1✔
195

196
    def checkin_item_on_shelf(self, **kwargs):
1✔
197
        """Checkin actions for an item on_shelf.
198

199
        :param item : the item record
200
        :param kwargs : all others named arguments
201
        """
202
        # CHECKIN_1_1: item on_shelf, no pending loans.
203
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
204
        transaction_item_libraries = libraries["transaction_item_libraries"]
1✔
205
        if transaction_item_libraries:
1✔
206
            # CHECKIN_1_1_1, item library = transaction library
207
            # item will be checked in in home library, no action
208
            if self.status != ItemStatus.ON_SHELF:
1✔
209
                self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
210
                message = _("No circulation action performed: item returned at owning library")
1✔
211
                NoCirculationOperationLog.create(item=self, message=message, index_refresh=True)
1✔
212
                raise NoCirculationAction(message)
1✔
213
            message = _("No circulation action performed: on shelf")
1✔
214
            NoCirculationOperationLog.create(item=self, message=message, index_refresh=True)
1✔
215
            raise NoCirculationAction(message)
1✔
216
        # CHECKIN_1_1_2: item library != transaction library
217
        # item will be checked-in in an external library, no
218
        # circulation action performed, add item status in_transit
219
        self["status"] = ItemStatus.IN_TRANSIT
1✔
220
        self.status_update(self, on_shelf=False, dbcommit=True, reindex=True, forceindex=True)
1✔
221
        message = _("No circulation action performed: in transit added")
1✔
222
        NoCirculationOperationLog.create(item=self, message=message, index_refresh=True)
1✔
223
        raise NoCirculationAction(message)
1✔
224

225
    def checkin_item_at_desk(self, **kwargs):
1✔
226
        """Checkin actions for at_desk item.
227

228
        :param item : the item record
229
        :param kwargs : all others named arguments
230
        """
231
        # CHECKIN_2: item at_desk
232
        at_desk_loan = self.get_first_loan_by_state(state=LoanState.ITEM_AT_DESK)
1✔
233
        kwargs["pickup_location_pid"] = at_desk_loan["pickup_location_pid"]
1✔
234
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
235
        if libraries["transaction_pickup_libraries"]:
1✔
236
            # CHECKIN_2_1: pickup location = transaction library
237
            # (no action, item is: at_desk (ITEM_AT_DESK))
238
            message = _("No circulation action performed: item at desk")
1✔
239
            NoCirculationOperationLog.create(item=self, loan=at_desk_loan, message=message, index_refresh=True)
1✔
240
            raise NoCirculationAction(message)
1✔
241

242
        # CHECKIN_2_2: pickup location != transaction library
243
        # item is: in_transit
244
        at_desk_loan["state"] = LoanState.ITEM_IN_TRANSIT_FOR_PICKUP
1✔
245
        at_desk_loan.update(at_desk_loan, dbcommit=True, reindex=True)
1✔
246
        self["status"] = ItemStatus.IN_TRANSIT
1✔
247
        self.status_update(self, on_shelf=False, dbcommit=True, reindex=True, forceindex=True)
1✔
248
        message = _("No circulation action performed: in transit added")
1✔
249
        NoCirculationOperationLog.create(item=self, loan=at_desk_loan, message=message, index_refresh=True)
1✔
250
        raise NoCirculationAction(message)
1✔
251

252
    def checkin_item_in_transit_for_pickup(self, **kwargs):
1✔
253
        """Checkin actions for item in_transit for pickup.
254

255
        :param item : the item record
256
        :param kwargs : all others named arguments
257
        """
258
        # CHECKIN_4: item in_transit (IN_TRANSIT_FOR_PICKUP)
259
        in_transit_loan = self.get_first_loan_by_state(state=LoanState.ITEM_IN_TRANSIT_FOR_PICKUP)
1✔
260
        kwargs["pickup_location_pid"] = in_transit_loan["pickup_location_pid"]
1✔
261
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
262
        if libraries["transaction_pickup_libraries"]:
1✔
263
            # CHECKIN_4_1: pickup location = transaction library
264
            # (delivery_receive current loan, item is: at_desk(ITEM_AT_DESK))
265
            kwargs["receive_in_transit_request"] = True
1✔
266
            return in_transit_loan, kwargs
1✔
267
        # CHECKIN_4_2: pickup location != transaction library
268
        # (no action, item is: in_transit (IN_TRANSIT_FOR_PICKUP))
269
        message = _("No circulation action performed: in transit added")
1✔
270
        NoCirculationOperationLog.create(item=self, loan=in_transit_loan, message=message, index_refresh=True)
1✔
271
        raise NoCirculationAction(message)
1✔
272

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

276
        :param item : the item record
277
        :param loans_list: list of loans states attached to the item
278
        :param kwargs : all others named arguments
279
        """
280
        # CHECKIN_5: item in_transit (IN_TRANSIT_TO_HOUSE)
281
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
282
        transaction_item_libraries = libraries["transaction_item_libraries"]
1✔
283
        in_transit_loan = self.get_first_loan_by_state(state=LoanState.ITEM_IN_TRANSIT_TO_HOUSE)
1✔
284
        if LoanState.PENDING not in loans_list:
1✔
285
            # CHECKIN_5_1: item has no pending loans
286
            if not transaction_item_libraries:
1✔
287
                # CHECKIN_5_1_2: item location != transaction library
288
                # (no action, item is: in_transit (IN_TRANSIT_TO_HOUSE))
289
                message = _("No circulation action performed: in transit to house")
1✔
290
                NoCirculationOperationLog.create(item=self, loan=in_transit_loan, message=message, index_refresh=True)
1✔
291
                raise NoCirculationAction(message)
1✔
292
            # CHECKIN_5_1_1: item location = transaction library
293
            # (house_receive current loan, item is: on_shelf)
294
            kwargs["receive_in_transit_request"] = True
1✔
295
            loan = in_transit_loan
1✔
296
        else:
297
            # CHECKIN_5_2: item has pending requests.
298
            loan, kwargs = self.checkin_item_in_transit_to_house_with_requests(in_transit_loan, **kwargs)
1✔
299
        return loan, kwargs
1✔
300

301
    def checkin_item_in_transit_to_house_with_requests(self, in_transit_loan, **kwargs):
1✔
302
        """Checkin actions for an item in IN_TRANSIT_TO_HOUSE with requests.
303

304
        :param item : the item record
305
        :param in_transit_loan: the in_transit loan attached to the item
306
        :param kwargs : all others named arguments
307
        """
308
        if pending := self.get_first_loan_by_state(state=LoanState.PENDING):
1✔
309
            pending_params = kwargs
1✔
310
            pending_params["pickup_location_pid"] = pending["pickup_location_pid"]
1✔
311
            libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
312
            if libraries["transaction_pickup_libraries"]:
1✔
313
                # CHECKIN_5_2_1_1: pickup location of first PENDING loan = item
314
                # library (house_receive current loan, item is: at_desk
315
                # [automatic validate first PENDING loan]
316
                if libraries["item_pickup_libraries"]:
1✔
317
                    kwargs["receive_current_and_validate_first"] = True
1✔
318
                else:
319
                    # CHECKIN_5_2_1_2: pickup location of first PENDING loan !=
320
                    # item library (cancel current loan, item is: at_desk
321
                    # automatic validate first PENDING loan
322
                    kwargs["cancel_current_and_receive_first"] = True
1✔
323
            else:
324
                # CHECKIN_5_2_2: pickup location of first PENDING loan !=
325
                # transaction library
326
                if libraries["item_pickup_libraries"]:
1✔
327
                    # CHECKIN_5_2_2_1: pickup location of first PENDING loan =
328
                    # item library (no action, item is: IN_TRANSIT)
329
                    message = _("No circulation action performed: in transit")
1✔
330
                    NoCirculationOperationLog.create(
1✔
331
                        item=self, loan=in_transit_loan, message=message, index_refresh=True
332
                    )
333
                    raise NoCirculationAction(message)
1✔
334
                # CHECKIN_5_2_2_2: pickup location of first PENDING loan !=
335
                # item library (checkin current loan, item is: in_transit)
336
                # [automatic cancel current, automatic validate first loan]
337
                kwargs["cancel_current_and_receive_first"] = True
1✔
338
        return in_transit_loan, kwargs
1✔
339

340
    def validate_item_first_pending_request(self, **kwargs):
1✔
341
        """Validate the first pending request for an item.
342

343
        :param item : the item record
344
        :param kwargs : all others named arguments
345
        """
346
        loan = None
1✔
347
        if pending := self.get_first_loan_by_state(state=LoanState.PENDING):
1✔
348
            # validate the first pending request.
349
            kwargs["validate_current_loan"] = True
1✔
350
            loan = pending
1✔
351
        return loan, kwargs
1✔
352

353
    def compare_item_pickup_transaction_libraries(self, **kwargs):
1✔
354
        """Compare item library, pickup and transaction libraries.
355

356
        :param kwargs : all others named arguments
357
        :return a dict comparison with the following boolean keys
358
            `transaction_item_libraries`: between transaction and item
359
            `transaction_pickup_libraries`: between transaction and pickup
360
            `item_pickup_libraries`: between item and pickup
361
        """
362
        trans_loc_pid = kwargs.pop("transaction_location_pid", None)
1✔
363
        trans_lib_pid = (
1✔
364
            kwargs.pop("transaction_library_pid", None) or Location.get_record_by_pid(trans_loc_pid).library_pid
365
        )
366

367
        pickup_loc_pid = kwargs.pop("pickup_location_pid", None)
1✔
368
        pickup_lib_pid = kwargs.pop("pickup_library_pid", None)
1✔
369
        if not pickup_lib_pid:
1✔
370
            if not pickup_loc_pid:
1✔
371
                pickup_lib_pid = trans_lib_pid
1✔
372
            else:
373
                pickup_lib_pid = Location.get_record_by_pid(pickup_loc_pid).library_pid
1✔
374

375
        return {
1✔
376
            "transaction_item_libraries": self.library_pid == trans_lib_pid,
377
            "transaction_pickup_libraries": pickup_lib_pid == trans_lib_pid,
378
            "item_pickup_libraries": self.library_pid == pickup_lib_pid,
379
        }
380

381
    @check_operation_allowed(ItemCirculationAction.CHECKOUT)
1✔
382
    @add_action_parameters_and_flush_indexes
1✔
383
    def checkout(self, current_loan, **kwargs):
1✔
384
        """Checkout item to the user."""
385
        action_params, actions = self.prior_checkout_actions(kwargs)
1✔
386
        loan = Loan.get_record_by_pid(action_params.get("pid"))
1✔
387
        current_loan = loan or Loan.create(action_params, dbcommit=True, reindex=True)
1✔
388
        old_state = current_loan.get("state")
1✔
389

390
        # If 'end_date' is specified, we need to check if the selected date is
391
        # not a closed date. If it's a closed date, then we need to update the
392
        # value to the next open day.
393
        if "end_date" in action_params:
1✔
394
            # circulation parameters are to calculate from transaction library.
395
            transaction_library_pid = (
1✔
396
                LocationsSearch().get_record_by_pid(kwargs.get("transaction_location_pid")).library.pid
397
            ) or self.library_pid
398
            library = Library.get_record_by_pid(transaction_library_pid)
1✔
399
            if not library.is_open(action_params["end_date"], True):
1✔
400
                # If library has no open dates, keep the default due date
401
                # to avoid circulation errors
402
                with suppress(LibraryNeverOpen):
1✔
403
                    new_end_date = library.next_open(action_params["end_date"])
1✔
404
                    new_end_date = new_end_date.astimezone().replace(microsecond=0).isoformat()
1✔
405
                    action_params["end_date"] = new_end_date
1✔
406
        # Call invenio_circulation for 'checkout' trigger
407
        loan = current_circulation.circulation.trigger(current_loan, **dict(action_params, trigger="checkout"))
1✔
408
        new_state = loan.get("state")
1✔
409
        if old_state == new_state:
1✔
UNCOV
410
            current_app.logger.error(
×
411
                f"Loan state has not changed after CHECKOUT: {loan.pid} state: {old_state} kwargs: {kwargs}"
412
            )
413
        actions.update({LoanAction.CHECKOUT: loan})
1✔
414
        return self, actions
1✔
415

416
    @add_action_parameters_and_flush_indexes
1✔
417
    def cancel_loan(self, current_loan, **kwargs):
1✔
418
        """Cancel a given item loan for a patron."""
419
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="cancel"))
1✔
420
        return self, {LoanAction.CANCEL: loan}
1✔
421

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

425
        during the cancelling process and according to the loan state.
426
        :param pid: the loan pid for the request to cancel.
427
        :return: the item record and list of actions performed.
428
        """
429
        actions = {}
1✔
430
        loan = Loan.get_record_by_pid(pid)
1✔
431
        # decide  which actions need to be executed according to loan state.
432
        actions_to_execute = self.checks_before_a_cancel_item_request(loan, **kwargs)
1✔
433
        # execute the actions
434
        if actions_to_execute.get("cancel_loan"):
1✔
435
            item, actions = self.cancel_loan(pid=loan.pid, **kwargs)
1✔
436
        if actions_to_execute.get("loan_update", {}).get("state"):
1✔
437
            loan["state"] = actions_to_execute["loan_update"]["state"]
1✔
438
            loan.update(loan, dbcommit=True, reindex=True)
1✔
439
            self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
440
            actions.update({LoanAction.UPDATE: loan})
1✔
441
        elif actions_to_execute.get("validate_first_pending"):
1✔
442
            pending = self.get_first_loan_by_state(state=LoanState.PENDING)
1✔
443
            loan_pickup = loan.get("pickup_location_pid", None)
1✔
444
            pending_pickup = pending.get("pickup_location_pid", None)
1✔
445
            # If the item is at_desk at the same location as the next loan
446
            # pickup we can validate the next loan so that it becomes at desk
447
            # for the next patron.
448
            if loan.get("state") == LoanState.ITEM_AT_DESK and loan_pickup == pending_pickup:
1✔
449
                item, actions = self.cancel_loan(pid=loan.pid, **kwargs)
1✔
450
                kwargs["transaction_location_pid"] = loan_pickup
1✔
451
                kwargs.pop("transaction_library_pid", None)
1✔
452
                item, validate_actions = self.validate_request(pid=pending.pid, **kwargs)
1✔
453
                actions.update(validate_actions)
1✔
454
            # Otherwise, we simply change the state of the next loan and it
455
            # will be validated at the next checkin at the pickup location.
456
            else:
457
                pending["state"] = LoanState.ITEM_IN_TRANSIT_FOR_PICKUP
1✔
458
                pending.update(pending, dbcommit=True, reindex=True)
1✔
459
                item, actions = self.cancel_loan(pid=loan.pid, **kwargs)
1✔
460
                self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
1✔
461
                actions.update({LoanAction.UPDATE: loan})
1✔
462
        item = self
1✔
463
        return item, actions
1✔
464

465
    def checks_before_a_cancel_item_request(self, loan, **kwargs):
1✔
466
        """Actions tobe executed before a cancel item request.
467

468
        :param loan : the current loan to cancel
469
        :param kwargs : all others named arguments
470
        :return: the item record and list of actions performed
471
        """
472
        actions_to_execute = {
1✔
473
            "cancel_loan": False,
474
            "loan_update": {},
475
            "validate_first_pending": False,
476
        }
477
        libraries = self.compare_item_pickup_transaction_libraries(**kwargs)
1✔
478
        # List all loan states attached to this item except the loan to cancel.
479
        # If the list is empty, no pending request/loan are linked to this item
480
        states = self.get_loans_states_by_item_pid_exclude_loan_pid(self.pid, loan.pid)
1✔
481
        if not states:
1✔
482
            if loan["state"] in [LoanState.PENDING, LoanState.ITEM_IN_TRANSIT_TO_HOUSE]:
1✔
483
                # CANCEL_REQUEST_1_2, CANCEL_REQUEST_5_1_1:
484
                # cancel the current loan is the only action
485
                actions_to_execute["cancel_loan"] = True
1✔
486
            elif loan["state"] == LoanState.ITEM_ON_LOAN:
1✔
487
                # CANCEL_REQUEST_3_1: no cancel action is possible on the loan
488
                # of a CHECKED_IN item.
489
                message = _("No circulation action performed: item on loan")
1✔
490
                NoCirculationOperationLog.create(item=self, loan=loan, message=message, index_refresh=True)
1✔
491
                raise NoCirculationAction(message)
1✔
492
            elif loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP:
1✔
493
                # CANCEL_REQUEST_4_1_1: cancelling a ITEM_IN_TRANSIT_FOR_PICKUP
494
                # loan with no pending request puts the item on in_transit
495
                # and the loan becomes ITEM_IN_TRANSIT_TO_HOUSE.
496
                actions_to_execute["loan_update"]["state"] = LoanState.ITEM_IN_TRANSIT_TO_HOUSE
1✔
497
                # Mark the loan to be cancelled to create an
498
                # OperationLog about this cancellation.
499
                actions_to_execute["cancel_loan"] = True
1✔
500
            elif loan["state"] == LoanState.ITEM_AT_DESK:
1✔
501
                if not libraries["item_pickup_libraries"]:
1✔
502
                    # CANCEL_REQUEST_2_1_1_1: when item library and pickup
503
                    # pickup library arent equal, update loan to go in_transit.
504
                    actions_to_execute["loan_update"]["state"] = LoanState.ITEM_IN_TRANSIT_TO_HOUSE
1✔
505
                # Always mark the loan to be cancelled to create an
506
                # OperationLog about this cancellation.
507
                actions_to_execute["cancel_loan"] = True
1✔
508
        elif loan["state"] == LoanState.ITEM_AT_DESK and LoanState.PENDING in states:
1✔
509
            # CANCEL_REQUEST_2_1_2: when item at desk with pending loan, cancel
510
            # the loan triggers an automatic validation of first pending loan.
511
            actions_to_execute["validate_first_pending"] = True
1✔
512
        elif loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP and LoanState.PENDING in states:
1✔
513
            # CANCEL_REQUEST_4_1_2: when item in_transit with pending loan,
514
            # cancel the loan triggers an automatic validation of 1st loan.
515
            actions_to_execute["validate_first_pending"] = True
1✔
516
        elif loan["state"] == LoanState.ITEM_IN_TRANSIT_TO_HOUSE and LoanState.PENDING in states:
1✔
517
            # CANCEL_REQUEST_5_1_2: when item in_transit with pending loan,
518
            # cancelling the loan triggers an automatic validation of first
519
            # pending loan.
520
            actions_to_execute["validate_first_pending"] = True
1✔
521
        elif loan["state"] == LoanState.PENDING and any(
1✔
522
            state in states
523
            for state in [
524
                LoanState.ITEM_AT_DESK,
525
                LoanState.ITEM_ON_LOAN,
526
                LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
527
                LoanState.ITEM_IN_TRANSIT_TO_HOUSE,
528
                LoanState.PENDING,
529
            ]
530
        ):
531
            # CANCEL_REQUEST_1_2, CANCEL_REQUEST_2_2, CANCEL_REQUEST_3_2,
532
            # CANCEL_REQUEST_4_2 CANCEL_REQUEST_5_2:
533
            # canceling a pending loan does not affect the other active loans.
534
            actions_to_execute["cancel_loan"] = True
1✔
535

536
        return actions_to_execute
1✔
537

538
    @add_action_parameters_and_flush_indexes
1✔
539
    def validate_request(self, current_loan, **kwargs):
1✔
540
        """Validate item request."""
541
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="validate_request"))
1✔
542
        return self, {LoanAction.VALIDATE: loan}
1✔
543

544
    @add_action_parameters_and_flush_indexes
1✔
545
    @check_operation_allowed(ItemCirculationAction.EXTEND)
1✔
546
    def extend_loan(self, current_loan, **kwargs):
1✔
547
        """Extend checkout duration for this item."""
548
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="extend"))
1✔
549
        return self, {LoanAction.EXTEND: loan}
1✔
550

551
    @check_operation_allowed(ItemCirculationAction.REQUEST)
1✔
552
    @add_action_parameters_and_flush_indexes
1✔
553
    def request(self, current_loan, **kwargs):
1✔
554
        """Request item for the user and create notifications."""
555
        old_state = current_loan.get("state")
1✔
556
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="request"))
1✔
557
        new_state = loan.get("state")
1✔
558
        if old_state == new_state:
1✔
UNCOV
559
            current_app.logger.error(
×
560
                f"Loan state has not changed after REQUEST: {loan.pid} state: {old_state} kwargs: {kwargs}"
561
            )
562
        return self, {LoanAction.REQUEST: loan}
1✔
563

564
    @add_action_parameters_and_flush_indexes
1✔
565
    def receive(self, current_loan, **kwargs):
1✔
566
        """Receive an item."""
567
        loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="receive"))
1✔
568
        return self, {LoanAction.RECEIVE: loan}
1✔
569

570
    def checkin_triggers_validate_current_loan(self, actions, **kwargs):
1✔
571
        """Validate the current loan.
572

573
        :param actions : dict the list of actions performed
574
        :param kwargs : all others named arguments
575
        :return:  the item record and list of actions performed
576
        """
577
        if kwargs.pop("validate_current_loan", None):
1✔
578
            item, validate_actions = self.validate_request(**kwargs)
1✔
579
            actions = {LoanAction.VALIDATE: validate_actions}
1✔
580
            actions |= validate_actions
1✔
581
            return item, actions
1✔
582
        return self, actions
1✔
583

584
    def actions_after_a_checkin(self, checkin_loan, actions, **kwargs):
1✔
585
        """Actions executed after a checkin.
586

587
        :param checkin_loan : the checked-in loan
588
        :param actions : dict the list of actions performed
589
        :param kwargs : all others named arguments
590
        :return:  the item record and list of actions performed
591
        """
592
        if not self.number_of_requests():
1✔
593
            return self, actions
1✔
594
        request = next(self.get_requests())
1✔
595
        if checkin_loan.is_active:
1✔
596
            params = kwargs
1✔
597
            params["pid"] = checkin_loan.pid
1✔
598
            item, cancel_actions = self.cancel_loan(**params)
1✔
599
            actions.update(cancel_actions)
1✔
600
        # pass the correct transaction location
601
        transaction_loc_pid = checkin_loan.get("transaction_location_pid")
1✔
602
        request["transaction_location_pid"] = transaction_loc_pid
1✔
603
        # validate the request
604
        item, validate_actions = self.validate_request(**request)
1✔
605
        actions.update(validate_actions)
1✔
606
        validate_loan = validate_actions[LoanAction.VALIDATE]
1✔
607
        # receive the request if it is requested at transaction library
608
        if validate_loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP:
1✔
609
            trans_loc = Location.get_record_by_pid(transaction_loc_pid)
1✔
610
            req_loc = Location.get_record_by_pid(request.get("pickup_location_pid"))
1✔
611
            if req_loc.library_pid == trans_loc.library_pid:
1✔
UNCOV
612
                item, receive_action = self.receive(**request)
×
UNCOV
613
                actions.update(receive_action)
×
614
        return item, actions
1✔
615

616
    def checkin_triggers_receive_in_transit_current_loan(self, actions, **kwargs):
1✔
617
        """Receive the item_in_transit_for_pickup loan.
618

619
        :param actions : dict the list of actions performed
620
        :param kwargs : all others named arguments
621
        :return: the item record and list of actions performed
622
        """
623
        if kwargs.pop("receive_in_transit_request", None):
1✔
624
            item, receive_action = self.receive(**kwargs)
1✔
625
            actions.update(receive_action)
1✔
626
            # receive_loan = receive_action[LoanAction.RECEIVE]
627
            return item, actions
1✔
628
        return self, actions
1✔
629

630
    def checkin_triggers_receive_and_validate_requests(self, actions, **kwargs):
1✔
631
        """Receive the item_in_transit_in_house and validate first loan.
632

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

665
    def checkin_triggers_cancel_and_receive_first_loan(self, current_loan, actions, **kwargs):
1✔
666
        """Cancel the current loan and receive the first request.
667

668
        :param current_loan : loan to cancel
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 not kwargs.pop("cancel_current_and_receive_first", None):
1✔
674
            return self, actions
1✔
675
        params = kwargs
1✔
676
        params["pid"] = current_loan.pid
1✔
677
        item, cancel_actions = self.cancel_loan(**params)
1✔
678
        actions.update(cancel_actions)
1✔
679
        cancel_loan = cancel_actions[LoanAction.CANCEL]
1✔
680
        if item.number_of_requests():
1✔
681
            request = next(item.get_requests())
1✔
682
            # pass the correct transaction location
683
            transaction_loc_pid = cancel_loan.get("transaction_location_pid")
1✔
684
            request["transaction_location_pid"] = transaction_loc_pid
1✔
685
            # validate the request
686
            item, validate_actions = item.validate_request(**request)
1✔
687
            actions.update(validate_actions)
1✔
688
            validate_loan = validate_actions[LoanAction.VALIDATE]
1✔
689
            # receive request if it is requested at transaction library
690
            if validate_loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP:
1✔
691
                trans_loc = Location.get_record_by_pid(transaction_loc_pid)
1✔
692
                req_loc = Location.get_record_by_pid(request.get("pickup_location_pid"))
1✔
693
                if req_loc.library_pid == trans_loc.library_pid:
1✔
UNCOV
694
                    item, receive_action = item.receive(**request)
×
UNCOV
695
                    actions.update(receive_action)
×
696
        return item, actions
1✔
697

698
    @add_action_parameters_and_flush_indexes
1✔
699
    def checkin(self, current_loan, **kwargs):
1✔
700
        """Perform a smart checkin action."""
701
        actions = {}
1✔
702
        # checkin actions for an item on_shelf
703
        item, actions = self.checkin_triggers_validate_current_loan(actions, **kwargs)
1✔
704
        if actions:
1✔
705
            return item, actions
1✔
706
        # checkin actions for an item in_transit with no requests
707
        item, actions = self.checkin_triggers_receive_in_transit_current_loan(actions, **kwargs)
1✔
708
        if actions:
1✔
709
            return item, actions
1✔
710
        # checkin actions for an item in_transit_to_house at home library
711
        item, actions = self.checkin_triggers_receive_and_validate_requests(actions, **kwargs)
1✔
712
        if actions:
1✔
713
            return item, actions
1✔
714
        # checkin actions for an item in_transit_to_house at external library
715
        item, actions = self.checkin_triggers_cancel_and_receive_first_loan(current_loan, actions, **kwargs)
1✔
716
        if actions:
1✔
717
            return item, actions
1✔
718
        # standard checkin actions
719
        checkin_loan = current_circulation.circulation.trigger(current_loan, **dict(kwargs, trigger="checkin"))
1✔
720
        actions = {LoanAction.CHECKIN: checkin_loan}
1✔
721
        # validate and receive actions to execute after a standard checkin
722
        item, actions = self.actions_after_a_checkin(checkin_loan, actions, **kwargs)
1✔
723
        return self, actions
1✔
724

725
    def prior_checkout_actions(self, action_params):
1✔
726
        """Actions executed prior to a checkout."""
727
        actions = {}
1✔
728
        if action_params.get("pid"):
1✔
729
            loan = Loan.get_record_by_pid(action_params.get("pid"))
1✔
730
            if loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP and loan.get("patron_pid") == action_params.get(
1✔
731
                "patron_pid"
732
            ):
733
                _, receive_actions = self.receive(**action_params)
1✔
734
                actions |= receive_actions
1✔
735
            elif loan["state"] == LoanState.ITEM_IN_TRANSIT_TO_HOUSE:
1✔
736
                # do not pass the patron_pid when cancelling a loan
737
                cancel_params = deepcopy(action_params)
1✔
738
                cancel_params.pop("patron_pid")
1✔
739
                _, cancel_actions = self.cancel_loan(**cancel_params)
1✔
740
                actions.update(cancel_actions)
1✔
741
                del action_params["pid"]
1✔
742
                # TODO: Check what's wrong in this case because Loan is cancel
743
                # but loan variable is not updated and after prior_checkout
744
                # a checkout is done on the item (it becomes ON_LOAN)
745
        else:
UNCOV
746
            loan = get_loan_for_item(item_pid_to_object(self.pid))
×
UNCOV
747
            if loan and loan["state"] != LoanState.ITEM_AT_DESK:
×
NEW
UNCOV
748
                _, cancel_actions = self.cancel_loan(pid=loan.get("pid"))
×
UNCOV
749
                actions.update(cancel_actions)
×
750
        # CHECKOUT_1_2_2: checkout denied if some pending loan are linked to it
751
        # with different patrons
752
        # Except while coming from an ITEM_IN_TRANSIT_TO_HOUSE loan because
753
        # the loan is cancelled and then came up in ON_SHELF to be checkout
754
        # by the second patron.
755
        if self.status == ItemStatus.ON_SHELF and loan["state"] != LoanState.ITEM_IN_TRANSIT_TO_HOUSE:
1✔
756
            for res in self.get_item_loans_by_state(state=LoanState.PENDING):
1✔
757
                if res.patron_pid != loan.get("patron_pid"):
1✔
758
                    item_pid = item_pid_to_object(self.pid)
1✔
759
                    msg = f"A pending loan exists for patron {res.patron_pid}"
1✔
760
                    raise ItemNotAvailableError(item_pid=item_pid, description=msg)
1✔
761
                # exit from loop after evaluation of the first request.
762
                break
1✔
763
        return action_params, actions
1✔
764

765
    @classmethod
1✔
766
    def get_loans_by_item_pid(cls, item_pid):
1✔
767
        """Return any loan loans for item."""
768
        item_pid_object = item_pid_to_object(item_pid)
1✔
769
        results = (
1✔
770
            current_circulation.loan_search_cls()
771
            .filter("term", item_pid__value=item_pid_object["value"])
772
            .filter("term", item_pid__type=item_pid_object["type"])
773
            .source(includes="pid")
774
            .scan()
775
        )
776
        for loan in results:
1✔
777
            yield Loan.get_record_by_pid(loan.pid)
1✔
778

779
    @classmethod
1✔
780
    def get_loans_states_by_item_pid_exclude_loan_pid(cls, item_pid, loan_pid):
1✔
781
        """Return list of loan states for an item excluding a given loan.
782

783
        :param item_pid : the item pid
784
        :param loan_pid : the loan pid to exclude
785
        :return:  the list of loans states attached to the item
786
        """
787
        exclude_states = [
1✔
788
            LoanState.ITEM_RETURNED,
789
            LoanState.CANCELLED,
790
            LoanState.CREATED,
791
        ]
792
        item_pid_object = item_pid_to_object(item_pid)
1✔
793
        results = (
1✔
794
            current_circulation.loan_search_cls()
795
            .filter("term", item_pid__value=item_pid_object["value"])
796
            .filter("term", item_pid__type=item_pid_object["type"])
797
            .exclude("terms", state=exclude_states)
798
            .source(includes="pid")
799
            .scan()
800
        )
801
        return [Loan.get_record_by_pid(loan.pid)["state"] for loan in results if loan.pid != loan_pid]
1✔
802

803
    @classmethod
1✔
804
    def get_loan_pid_with_item_on_loan(cls, item_pid):
1✔
805
        """Returns loan pid for checked out item."""
806
        search = search_by_pid(
1✔
807
            item_pid=item_pid_to_object(item_pid),
808
            filter_states=[LoanState.ITEM_ON_LOAN],
809
        )
810
        results = search.source(["pid"]).scan()
1✔
811
        try:
1✔
812
            return next(results).pid
1✔
813
        except StopIteration:
1✔
814
            return None
1✔
815

816
    @classmethod
1✔
817
    def get_pendings_loans(cls, library_pid=None, sort_by="_created"):
1✔
818
        """Return list of sorted pending loans for a given library.
819

820
        default sort is set to _created
821
        """
822
        # check if library exists
UNCOV
823
        lib = Library.get_record_by_pid(library_pid)
×
UNCOV
824
        if not lib:
×
UNCOV
825
            raise Exception("Invalid Library PID")
×
826
        # the '-' prefix means a desc order.
UNCOV
827
        sort_by = sort_by or "_created"
×
UNCOV
828
        order_by = "asc"
×
UNCOV
829
        if sort_by.startswith("-"):
×
UNCOV
830
            sort_by = sort_by[1:]
×
UNCOV
831
            order_by = "desc"
×
832

UNCOV
833
        results = (
×
834
            current_circulation.loan_search_cls()
835
            .params(preserve_order=True)
836
            .filter("term", state=LoanState.PENDING)
837
            .filter("term", library_pid=library_pid)
838
            .sort({sort_by: {"order": order_by}})
839
            .source(includes="pid")
840
            .scan()
841
        )
UNCOV
842
        for loan in results:
×
UNCOV
843
            yield Loan.get_record_by_pid(loan.pid)
×
844

845
    @classmethod
1✔
846
    def get_checked_out_loan_infos(cls, patron_pid, sort_by="_created"):
1✔
847
        """Returns sorted checked out loans for a given patron."""
848
        # the '-' prefix means a desc order.
849
        sort_by = sort_by or "_created"
1✔
850
        order_by = "asc"
1✔
851
        if sort_by.startswith("-"):
1✔
852
            sort_by = sort_by[1:]
1✔
853
            order_by = "desc"
1✔
854

855
        results = (
1✔
856
            search_by_patron_item_or_document(patron_pid=patron_pid, filter_states=[LoanState.ITEM_ON_LOAN])
857
            .params(preserve_order=True)
858
            .sort({sort_by: {"order": order_by}})
859
            .source(["pid", "item_pid.value"])
860
            .scan()
861
        )
862
        for data in results:
1✔
863
            yield data.pid, data.item_pid.value
1✔
864

865
    def get_last_location(self):
1✔
866
        """Returns the location record of the transaction location.
867

868
        of the last loan.
869
        """
870
        return Location.get_record_by_pid(self.last_location_pid)
1✔
871

872
    @property
1✔
873
    def last_location_pid(self):
1✔
874
        """Returns the location pid of the circulation transaction location.
875

876
        of the last loan.
877
        """
878
        loan_location_pid = get_last_transaction_loc_for_item(self.pid)
1✔
879
        if loan_location_pid and Location.get_record_by_pid(loan_location_pid):
1✔
UNCOV
880
            return loan_location_pid
×
881
        return self.location_pid
1✔
882

883
    def patron_has_an_active_loan_on_item(self, patron):
1✔
884
        """Return True if patron has an active loan on the item.
885

886
        The new circ specs do allow requests on ITEM_IN_TRANSIT_TO_HOUSE loans.
887

888
        :param patron_barcode: the patron barcode.
889
        :return: True is requested otherwise False.
890
        """
891
        if patron:
1✔
892
            search = (
1✔
893
                search_by_patron_item_or_document(
894
                    item_pid=item_pid_to_object(self.pid),
895
                    patron_pid=patron.pid,
896
                    filter_states=[
897
                        LoanState.PENDING,
898
                        LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
899
                        LoanState.ITEM_AT_DESK,
900
                        LoanState.ITEM_ON_LOAN,
901
                    ],
902
                )
903
                .params(preserve_order=True)
904
                .source(["state"])
905
            )
906
            return len(list(dict.fromkeys([result.state for result in search.scan()]))) > 0
1✔
UNCOV
907
        return None
×
908

909
    # CIRCULATION METHODS =====================================================
910
    def can(self, action, **kwargs):
1✔
911
        """Check if a specific action is allowed on this item.
912

913
        :param action : the action to check as ItemCirculationAction part
914
        :param kwargs : all others named arguments useful to check
915
        :return a tuple with True|False to know if the action is possible and
916
                a list of reasons to disallow if False.
917
        """
918
        can, reasons = True, []
1✔
919
        actions = current_app.config.get("CIRCULATION_ACTIONS_VALIDATION", {})
1✔
920
        for func_name in actions.get(action, []):
1✔
921
            func_callback = obj_or_import_string(func_name)
1✔
922
            func_can, func_reasons = func_callback(self, **kwargs)
1✔
923
            reasons += func_reasons
1✔
924
            can = can and func_can
1✔
925
        return can, reasons
1✔
926

927
    @classmethod
1✔
928
    def can_request(cls, item, **kwargs):
1✔
929
        """Check if an item can be requested regarding the item status.
930

931
        :param item : the item to check.
932
        :param kwargs : other arguments.
933
        :return a tuple with True|False and reasons to disallow if False.
934
        """
935
        reasons = []
1✔
936
        if item.status in [ItemStatus.MISSING, ItemStatus.EXCLUDED]:
1✔
UNCOV
937
            reasons.append(_("Item status disallows the operation."))
×
938
        if "patron" in kwargs:
1✔
939
            patron = kwargs["patron"]
1✔
940
            if patron.organisation_pid != item.organisation_pid:
1✔
UNCOV
941
                reasons.append(_("Item and patron are not in the same organisation."))
×
942
            if patron.patron.get("barcode") and item.patron_has_an_active_loan_on_item(patron):
1✔
943
                reasons.append(_("Item is already checked-out or requested by patron."))
1✔
944
        return not reasons, reasons
1✔
945

946
    def action_filter(self, action, organisation_pid, library_pid, loan, patron_pid, patron_type_pid):
1✔
947
        """Filter actions."""
948
        circ_policy = CircPolicy.provide_circ_policy(
1✔
949
            organisation_pid,
950
            library_pid,
951
            patron_type_pid,
952
            self.item_type_circulation_category_pid,
953
        )
954
        data = {"action_validated": True, "new_action": None}
1✔
955
        if action == "extend":
1✔
956
            can, _ = self.can(ItemCirculationAction.EXTEND, loan=loan)
1✔
957
            if not can:
1✔
958
                data["action_validated"] = False
1✔
959
        if action == "checkout" and not circ_policy.can_checkout:
1✔
UNCOV
960
            data["action_validated"] = False
×
961
        elif (
1✔
962
            action == "receive"
963
            and circ_policy.can_checkout
964
            and loan["state"] == LoanState.ITEM_IN_TRANSIT_FOR_PICKUP
965
            and loan.get("patron_pid") == patron_pid
966
        ):
967
            data["action_validated"] = False
1✔
968
            data["new_action"] = "checkout"
1✔
969
        return data
1✔
970

971
    @property
1✔
972
    def actions(self):
1✔
973
        """Get all available actions."""
974
        transitions = current_app.config.get("CIRCULATION_LOAN_TRANSITIONS")
1✔
975
        loan = get_loan_for_item(item_pid_to_object(self.pid))
1✔
976
        actions = set()
1✔
977
        if loan:
1✔
978
            organisation_pid = self.organisation_pid
1✔
979
            library_pid = self.library_pid
1✔
980
            patron_pid = loan.get("patron_pid")
1✔
981
            patron_type_pid = Patron.get_record_by_pid(patron_pid).patron_type_pid
1✔
982
            for transition in transitions.get(loan["state"]):
1✔
983
                action = transition.get("trigger")
1✔
984
                data = self.action_filter(
1✔
985
                    action=action,
986
                    organisation_pid=organisation_pid,
987
                    library_pid=library_pid,
988
                    loan=loan,
989
                    patron_pid=patron_pid,
990
                    patron_type_pid=patron_type_pid,
991
                )
992

993
                if data.get("action_validated"):
1✔
994
                    actions.add(action)
1✔
995
                if data.get("new_action"):
1✔
996
                    actions.add(data.get("new_action"))
1✔
997
        # default actions
998

999
        if not loan:
1✔
1000
            for transition in transitions.get(LoanState.CREATED):
1✔
1001
                action = transition.get("trigger")
1✔
1002
                actions.add(action)
1✔
1003
        # remove unsupported action
1004
        for action in ["cancel", "request"]:
1✔
1005
            with suppress(KeyError):
1✔
1006
                actions.remove(action)
1✔
1007
        # rename
1008
        with suppress(KeyError):
1✔
1009
            actions.remove("extend")
1✔
1010
            actions.add("extend_loan")
1✔
1011
        # if self['status'] == ItemStatus.MISSING:
1012
        #     actions.add('return_missing')
1013
        # else:
1014
        #     actions.add('lose')
1015
        return actions
1✔
1016

1017
    @classmethod
1✔
1018
    def status_update(cls, item, on_shelf=True, dbcommit=False, reindex=False, forceindex=False):
1✔
1019
        """Update item status.
1020

1021
        The item normally inherits its status from its active loan. In other
1022
        cases it goes back to on_shelf
1023

1024
        :param item: the item record
1025
        :param on_shelf: A boolean to indicate that item is candidate to go
1026
                         on_shelf
1027
        :param dbcommit: commit record to database
1028
        :param reindex: reindex record
1029
        :param forceindex: force the reindexation
1030
        """
1031
        if loan := get_loan_for_item(item_pid_to_object(item.pid)):
1✔
1032
            item["status"] = cls.statuses[loan["state"]]
1✔
1033
        elif item["status"] != ItemStatus.MISSING and on_shelf:
1✔
1034
            item["status"] = ItemStatus.ON_SHELF
1✔
1035
        item.commit()
1✔
1036
        if dbcommit:
1✔
1037
            item.dbcommit(reindex=True, forceindex=True)
1✔
1038

1039
    def item_has_active_loan_or_request(self):
1✔
1040
        """Return True if active loan or a request found for item."""
1041
        states = [LoanState.PENDING] + current_app.config["CIRCULATION_STATES_LOAN_ACTIVE"]
1✔
1042
        search = search_by_pid(
1✔
1043
            item_pid=item_pid_to_object(self.pid),
1044
            filter_states=states,
1045
        )
1046
        return bool(search.count())
1✔
1047

1048
    def return_missing(self):
1✔
1049
        """Return the missing item.
1050

1051
        The item's status will be set to ItemStatus.ON_SHELF.
1052
        """
1053
        # TODO: check transaction location
UNCOV
1054
        self["status"] = ItemStatus.ON_SHELF
×
UNCOV
1055
        self.status_update(self, dbcommit=True, reindex=True, forceindex=True)
×
UNCOV
1056
        return self, {LoanAction.RETURN_MISSING: None}
×
1057

1058
    def get_links_to_me(self, get_pids=False):
1✔
1059
        """Record links.
1060

1061
        :param get_pids: if True list of linked pids
1062
                         if False count of linked records
1063
        """
1064
        # avoid circular import
1065
        from rero_ils.modules.collections.api import CollectionsSearch
1✔
1066
        from rero_ils.modules.local_fields.api import LocalFieldsSearch
1✔
1067

1068
        query_loans = search_by_pid(
1✔
1069
            item_pid=item_pid_to_object(self.pid),
1070
            exclude_states=[
1071
                LoanState.CREATED,
1072
                LoanState.CANCELLED,
1073
                LoanState.ITEM_RETURNED,
1074
            ],
1075
        )
1076
        query_fees = (
1✔
1077
            PatronTransactionsSearch()
1078
            .filter("term", item__pid=self.pid)
1079
            .filter("term", status="open")
1080
            .filter("range", total_amount={"gt": 0})
1081
        )
1082
        query_collections = CollectionsSearch().filter("term", items__pid=self.pid)
1✔
1083
        query_local_fields = LocalFieldsSearch().get_local_fields(self.provider.pid_type, self.pid)
1✔
1084

1085
        if get_pids:
1✔
1086
            loans = sorted_pids(query_loans)
1✔
1087
            fees = sorted_pids(query_fees)
1✔
1088
            collections = sorted_pids(query_collections)
1✔
1089
            local_fields = sorted_pids(query_local_fields)
1✔
1090
        else:
1091
            loans = query_loans.count()
1✔
1092
            fees = query_fees.count()
1✔
1093
            collections = query_collections.count()
1✔
1094
            local_fields = query_local_fields.count()
1✔
1095
        links = {
1✔
1096
            "loans": loans,
1097
            "fees": fees,
1098
            "collections": collections,
1099
            "local_fields": local_fields,
1100
        }
1101
        return {k: v for k, v in links.items() if v}
1✔
1102

1103
    def get_requests(self, sort_by=None, output=None):
1✔
1104
        """Return sorted pending, item_on_transit, item_at_desk loans.
1105

1106
        :param sort_by: the sort to result. default sort is _created.
1107
        :param output: the type of output. 'pids', 'count' or 'obj' (default)
1108
        :return depending of output parameter:
1109
            - 'obj': a generator ``Loan`` objects.
1110
            - 'count': the request counter.
1111
            - 'pids': the request pids list
1112
        """
1113

1114
        def _list_obj():
1✔
1115
            order_by = "asc"
1✔
1116
            sort_term = sort_by or "_created"
1✔
1117
            if sort_term.startswith("-"):
1✔
UNCOV
1118
                (sort_term, order_by) = (sort_term[1:], "desc")
×
1119
            es_query = query.params(preserve_order=True).sort({sort_term: {"order": order_by}})
1✔
1120
            for result in es_query.scan():
1✔
1121
                yield Loan.get_record_by_pid(result.pid)
1✔
1122

1123
        query = search_by_pid(
1✔
1124
            item_pid=item_pid_to_object(self.pid),
1125
            filter_states=[
1126
                LoanState.PENDING,
1127
                LoanState.ITEM_AT_DESK,
1128
                LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
1129
            ],
1130
        ).source(["pid"])
1131
        if output == "pids":
1✔
1132
            return [hit.pid for hit in query.scan()]
1✔
1133
        return query.count() if output == "count" else _list_obj()
1✔
1134

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

1138
        :param state : the loan state
1139
        :return: first loan found otherwise None
1140
        """
1141
        try:
1✔
1142
            return next(self.get_item_loans_by_state(state=state))
1✔
1143
        except StopIteration:
1✔
1144
            return None
1✔
1145

1146
    def get_item_loans_by_state(self, state=None, sort_by=None):
1✔
1147
        """Return sorted item loans with a given state.
1148

1149
        default sort is _created.
1150
        :param state : the loan state
1151
        :param sort_by : field to use for sorting
1152
        :return: loans found
1153
        """
1154
        search = (
1✔
1155
            search_by_pid(item_pid=item_pid_to_object(self.pid), filter_states=[state])
1156
            .params(preserve_order=True)
1157
            .source(["pid"])
1158
        )
1159
        order_by = "asc"
1✔
1160
        sort_by = sort_by or "_created"
1✔
1161
        if sort_by.startswith("-"):
1✔
UNCOV
1162
            sort_by = sort_by[1:]
×
UNCOV
1163
            order_by = "desc"
×
1164
        search = search.sort({sort_by: {"order": order_by}})
1✔
1165
        for result in search.scan():
1✔
1166
            yield Loan.get_record_by_pid(result.pid)
1✔
1167

1168
    def get_loan_states_for_an_item(self):
1✔
1169
        """Return list of all the loan states attached to the item.
1170

1171
        :return: list of all loan states attached to the item
1172
        """
1173
        search = (
1✔
1174
            search_by_pid(
1175
                item_pid=item_pid_to_object(self.pid),
1176
                filter_states=[
1177
                    LoanState.PENDING,
1178
                    LoanState.ITEM_IN_TRANSIT_FOR_PICKUP,
1179
                    LoanState.ITEM_IN_TRANSIT_TO_HOUSE,
1180
                    LoanState.ITEM_AT_DESK,
1181
                    LoanState.ITEM_ON_LOAN,
1182
                ],
1183
            )
1184
            .params(preserve_order=True)
1185
            .source(["state"])
1186
        )
1187
        return list(dict.fromkeys([result.state for result in search.scan()]))
1✔
1188

1189
    def is_available(self):
1✔
1190
        """Get availability for item.
1191

1192
        Note: if the logic has to be changed here please check also for
1193
        documents and holdings availability.
1194
        """
1195
        from ..api import ItemsSearch
1✔
1196

1197
        items_query = ItemsSearch().available_query()
1✔
1198

1199
        # check item availability
1200
        if not items_query.filter("term", pid=self.pid).count():
1✔
1201
            return False
1✔
1202

1203
        # --------------- Loans -------------------
1204
        # unavailable if the current item has active loans
1205
        return not self.item_has_active_loan_or_request()
1✔
1206

1207
    @property
1✔
1208
    def availability_text(self):
1✔
1209
        """Availability text to display for an item."""
1210
        circ_category = self.circulation_category
1✔
1211
        if circ_category.get("negative_availability"):
1✔
1212
            return [
1✔
1213
                *circ_category.get("displayed_status", []),
1214
                {"language": "default", "label": circ_category.get("name")},
1215
            ]
1216
        label = self.status
1✔
1217
        if self.is_issue and self.issue_status != ItemIssueStatus.RECEIVED:
1✔
1218
            label = self.issue_status
1✔
1219
        return [{"language": "default", "label": label}]
1✔
1220

1221
    @property
1✔
1222
    def temp_item_type_negative_availability(self):
1✔
1223
        """Get the temporary item type neg availability."""
1224
        if self.get("temporary_item_type"):
1✔
1225
            return ItemType.get_record_by_pid(extracted_data_from_ref(self.get("temporary_item_type"))).get(
1✔
1226
                "negative_availability", False
1227
            )
1228
        return False
1✔
1229

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

1233
        :param format: The date format, ex: 'full', 'medium', 'short'
1234
                        or custom
1235
        :param time_format: The time format, ex: 'medium', 'short' or custom
1236
        :param language: The language to fix the language format
1237
        :return: original date, formatted date or None
1238
        """
1239
        if loan := get_loan_for_item(item_pid_to_object(self.pid)):
1✔
1240
            end_date = loan["end_date"]
1✔
1241
            if format:
1✔
1242
                return format_date_filter(
1✔
1243
                    end_date,
1244
                    date_format=format,
1245
                    time_format=time_format,
1246
                    locale=language,
1247
                )
UNCOV
1248
            return end_date
×
UNCOV
1249
        return None
×
1250

1251
    def get_extension_count(self):
1✔
1252
        """Get item renewal count."""
1253
        if loan := get_loan_for_item(item_pid_to_object(self.pid)):
1✔
1254
            return loan.get("extension_count", 0)
1✔
UNCOV
1255
        return 0
×
1256

1257
    def number_of_requests(self):
1✔
1258
        """Get number of requests for a given item."""
1259
        return self.get_requests(output="count")
1✔
1260

1261
    def patron_request_rank(self, patron):
1✔
1262
        """Get the rank of patron in list of requests on this item."""
1263
        if patron:
1✔
1264
            requests = self.get_requests()
1✔
1265
            for rank, request in enumerate(requests, start=1):
1✔
1266
                if request["patron_pid"] == patron.pid:
1✔
1267
                    return rank
1✔
UNCOV
1268
        return 0
×
1269

1270
    def is_requested_by_patron(self, patron_barcode):
1✔
1271
        """Check if the item is requested by a given patron."""
1272
        if patron := Patron.get_patron_by_barcode(barcode=patron_barcode, org_pid=self.organisation_pid):
1✔
1273
            if get_request_by_item_pid_by_patron_pid(item_pid=self.pid, patron_pid=patron.pid):
1✔
1274
                return True
1✔
1275
        return False
1✔
1276

1277
    @classmethod
1✔
1278
    def get_requests_to_validate(cls, library_pid=None, sort_by=None):
1✔
1279
        """Returns list of requests to validate for a given library."""
UNCOV
1280
        loans = cls.get_pendings_loans(library_pid=library_pid, sort_by=sort_by)
×
UNCOV
1281
        returned_item_pids = []
×
UNCOV
1282
        for loan in loans:
×
UNCOV
1283
            item_pid = loan.get("item_pid", {}).get("value")
×
UNCOV
1284
            item = cls.get_record_by_pid(item_pid)
×
UNCOV
1285
            if item.status == ItemStatus.ON_SHELF and item_pid not in returned_item_pids:
×
UNCOV
1286
                returned_item_pids.append(item_pid)
×
UNCOV
1287
                yield item, loan
×
1288

1289
    @staticmethod
1✔
1290
    def item_exists(item_pid):
1✔
1291
        """Returns true if item exists for the given item_pid.
1292

1293
        :param item_pid: the item_pid object
1294
        :type item_pid: object
1295
        :return: True if item found otherwise False
1296
        :rtype: bool
1297
        """
1298
        from .api import Item
1✔
1299

1300
        try:
1✔
1301
            Item.get_record_by_pid(item_pid.get("value"))
1✔
UNCOV
1302
        except PersistentIdentifierError:
×
UNCOV
1303
            return False
×
1304
        return True
1✔
1305

1306
    @classmethod
1✔
1307
    def get_checked_out_items(cls, patron_pid=None, sort_by=None):
1✔
1308
        """Return sorted checked out items for a given patron."""
1309
        from .api import Item
1✔
1310

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

© 2025 Coveralls, Inc