• 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

96.52
/rero_ils/modules/api.py
1
# SPDX-FileCopyrightText: Fondation RERO+
2
# SPDX-FileCopyrightText: UCLouvain
3
# SPDX-License-Identifier: AGPL-3.0-or-later
4

5
"""API for manipulating records."""
6

7
import re
1✔
8
from copy import deepcopy
1✔
9
from uuid import uuid4
1✔
10

11
import click
1✔
12
from celery import current_app as current_celery_app
1✔
13
from elasticsearch.exceptions import NotFoundError
1✔
14
from elasticsearch.helpers import bulk
1✔
15
from flask import current_app
1✔
16
from invenio_db import db
1✔
17
from invenio_indexer.api import RecordIndexer
1✔
18
from invenio_indexer.signals import before_record_index
1✔
19
from invenio_pidstore.errors import PIDDoesNotExistError
1✔
20
from invenio_pidstore.models import PersistentIdentifier, PIDStatus
1✔
21
from invenio_records.api import Record
1✔
22
from invenio_records_rest.utils import obj_or_import_string
1✔
23
from invenio_search import current_search
1✔
24
from invenio_search.api import RecordsSearch
1✔
25
from invenio_search.engine import search
1✔
26
from jsonschema import FormatChecker
1✔
27
from jsonschema.exceptions import ValidationError
1✔
28
from kombu.compat import Consumer
1✔
29
from sqlalchemy import text
1✔
30
from sqlalchemy.orm.exc import NoResultFound
1✔
31

32
from .utils import extracted_data_from_ref
1✔
33

34
"""Custom ILS record JSON schema format validator."""
1✔
35
ils_record_format_checker = FormatChecker()
1✔
36

37

38
@ils_record_format_checker.checks("email")
1✔
39
@ils_record_format_checker.checks("idn-email")
1✔
40
def _strong_email_validation(instance):
1✔
41
    """Allow to validate an email address (only email format, not DNS)."""
42
    if not isinstance(instance, str):
1✔
43
        return False
×
44
    # DEV NOTE : If this regexp changes, you also need to alter the regexp
45
    # into `email.validator.ts` file into @rero/ng-core. The best solution
46
    # should be to use a configuration setting available through an API, but
47
    # it should be a little overkill.
48
    email_regexp = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
1✔
49
    return bool(re.fullmatch(email_regexp, instance))
1✔
50

51

52
class IlsRecordError:
1✔
53
    """Base class for errors in the IlsRecordClass."""
54

55
    class Deleted(Exception):
1✔
56
        """IlsRecord is deleted."""
57

58
    class NotDeleted(Exception):
1✔
59
        """IlsRecord is not deleted."""
60

61
    class PidMissing(Exception):
1✔
62
        """IlsRecord pid missing."""
63

64
    class PidChange(Exception):
1✔
65
        """IlsRecord pid change."""
66

67
    class PidAlreadyUsed(Exception):
1✔
68
        """IlsRecord pid already used."""
69

70
    class PidDoesNotExist(Exception):
1✔
71
        """Pid does not exist."""
72

73
    class DataMissing(Exception):
1✔
74
        """Data missing in record."""
75

76

77
class IlsRecordsSearch(RecordsSearch):
1✔
78
    """Search Class for ils."""
79

80
    class Meta:
1✔
81
        """Search only on item index."""
82

83
        index = "records"
1✔
84
        doc_types = None
1✔
85
        fields = ("*",)
1✔
86
        facets = {}
1✔
87

88
        default_filter = None
1✔
89

90
    @classmethod
1✔
91
    def flush_and_refresh(cls):
1✔
92
        """Flush and refresh index."""
93
        current_search.flush_and_refresh(cls.Meta.index)
1✔
94

95
    def get_record_by_pid(self, pid, fields=None):
1✔
96
        """Get ElasticSearch hit by pid.
97

98
        :param pid: the record PID to retrieve.
99
        :param fields: a list of field to return. If ``None`` all fields will
100
            be returned.
101
        :returns: An ``AttrDict`` representing the ElasticSearch hit found.
102
        :raise NotFoundError: If the record cannot be found into ElasticSearch
103
        """
104
        if hit := next(self.get_records_by_pids([pid], fields), None):
1✔
105
            return hit
1✔
106
        raise NotFoundError(f"Record not found pid: {pid}")
1✔
107

108
    def get_records_by_pids(self, pids, fields=None):
1✔
109
        """Get search hits by pids.
110

111
        :param pids: the list of record pids to retrieve.
112
        :param fields: a list of field to return. If ``None`` all fields will
113
            be returned.
114
        :returns: A generator of ``AttrDict`` representing ElasticSearch hits
115
            found.
116
        """
117
        assert type(pids) is list
1✔
118
        query = self.filter("terms", pid=pids)
1✔
119
        if fields:
1✔
120
            query = query.source(includes=fields)
1✔
121
        return query.scan()
1✔
122

123
    def get_records_by_terms(self, terms, key="pid", fields=None, as_dict=False):
1✔
124
        """Search records by terms.
125

126
        :param terms: list of term to search
127
        :param key: key used for search terms
128
        :param fields: list of field to get
129
        :param as_dict: if true the search result will be converted to dict
130
        :return: dictionary if as_dict is true else return generator
131
        :rtype: dictionary or generator
132
        """
133
        fields = fields or "*"
1✔
134
        params = {key: terms}
1✔
135
        query = self.filter("terms", **params).source(includes=fields)
1✔
136

137
        if as_dict:
1✔
138
            return {result.pid: result.to_dict() for result in query.scan()}
1✔
139

140
        return query.scan()
1✔
141

142

143
class IlsRecord(Record):
1✔
144
    """ILS Record class."""
145

146
    minter = None
1✔
147
    fetcher = None
1✔
148
    provider = None
1✔
149
    object_type = "rec"
1✔
150
    pids_exist_check = None
1✔
151
    pid_check = True
1✔
152

153
    format_checker = ils_record_format_checker
1✔
154

155
    @classmethod
1✔
156
    def get_indexer_class(cls):
1✔
157
        """Get the indexer from config."""
158
        endpoints = current_app.config["RECORDS_REST_ENDPOINTS"]
1✔
159
        endpoints.update(current_app.config["CIRCULATION_REST_ENDPOINTS"])
1✔
160
        try:
1✔
161
            indexer = obj_or_import_string(endpoints[cls.provider.pid_type]["indexer_class"])
1✔
162
        except Exception:
1✔
163
            # provide default indexer if no indexer is defined in config.
164
            indexer = IlsRecordsIndexer
1✔
165
        return indexer
1✔
166

167
    def _validate(self, **kwargs):
1✔
168
        """Validate record against schema.
169

170
        extended validation per record class
171
        and test of pid existence.
172
        """
173
        if self.get("_draft"):
1✔
174
            # No validation is needed for draft records
175
            return self
1✔
176

177
        json = super()._validate(**kwargs)
1✔
178
        validation_message = self.extended_validation(**kwargs)
1✔
179
        # We only like to run pids_exist_check if validation_message is True
180
        # and not a string with error from extended_validation
181
        if validation_message is True and self.pid_check and self.pids_exist_check:
1✔
182
            from .utils import pids_exists_in_data
1✔
183

184
            validation_message = (
1✔
185
                pids_exists_in_data(
186
                    info=f"{self.provider.pid_type} ({self.pid})",
187
                    data=self,
188
                    required=self.pids_exist_check.get("required", {}),
189
                    not_required=self.pids_exist_check.get("not_required", {}),
190
                )
191
                or True
192
            )
193
        if validation_message is not True:
1✔
194
            if not isinstance(validation_message, list):
1✔
195
                validation_message = [validation_message]
1✔
196
            raise ValidationError(";".join(validation_message))
1✔
197
        return json
1✔
198

199
    def extended_validation(self, **kwargs):
1✔
200
        """Returns reasons for validation failures, otherwise True.
201

202
        Override this function for classes that require extended validations
203
        """
204
        return True
1✔
205

206
    @classmethod
1✔
207
    def create(
1✔
208
        cls,
209
        data,
210
        id_=None,
211
        delete_pid=False,
212
        dbcommit=False,
213
        reindex=False,
214
        pidcheck=True,
215
        **kwargs,
216
    ):
217
        """Create a new ils record."""
218
        assert cls.minter
1✔
219
        assert cls.provider
1✔
220
        if "$schema" not in data:
1✔
221
            pid_type = cls.provider.pid_type
1✔
222
            schemas = current_app.config.get("RERO_ILS_DEFAULT_JSON_SCHEMA")
1✔
223
            if pid_type in schemas:
1✔
224
                from .utils import get_schema_for_resource
1✔
225

226
                data["$schema"] = get_schema_for_resource(pid_type)
1✔
227
        pid = data.get("pid")
1✔
228
        if delete_pid and pid:
1✔
229
            del data["pid"]
1✔
230
        elif pid:
1✔
231
            if test_rec := cls.get_record_by_pid(pid):
1✔
232
                raise IlsRecordError.PidAlreadyUsed(
1✔
233
                    f"PidAlreadyUsed {cls.provider.pid_type} {test_rec.pid} {test_rec.id}"
234
                )
235
        if not id_:
1✔
236
            id_ = uuid4()
1✔
237
        cls.minter(id_, data)
1✔
238
        cls.pid_check = pidcheck
1✔
239
        try:
1✔
240
            record = super().create(data=data, id_=id_, **kwargs)
1✔
241
            if record and dbcommit:
1✔
242
                record.dbcommit(reindex)
1✔
243
        except Exception as err:
1✔
244
            current_app.logger.warning(f"CREATE WARNING: {cls.__name__}: {err} data:{data}")
1✔
245
            raise
1✔
246
        return record
1✔
247

248
    @classmethod
1✔
249
    def get_record_by_pid(cls, pid, with_deleted=False, verbose=False):
1✔
250
        """Get ILS record by pid value."""
251
        if verbose:
1✔
252
            click.echo(f"\t\tget_record_by_pid: {cls.__name__} {pid}")
×
253
        if pid:
1✔
254
            assert cls.provider
1✔
255
            try:
1✔
256
                persistent_identifier = PersistentIdentifier.get(cls.provider.pid_type, pid)
1✔
257
                return super().get_record(persistent_identifier.object_uuid, with_deleted=with_deleted)
1✔
258
            # TODO: is it better to raise a error or to return None?
259
            except NoResultFound, PIDDoesNotExistError:
1✔
260
                return None
1✔
261
        return None
1✔
262

263
    @classmethod
1✔
264
    def get_records_by_pids(cls, pids):
1✔
265
        """Get ILS records by pid values.
266

267
        :param pids: Object pid list to retrieve.
268
        :return: Generator of ILS resource.
269
        """
270
        assert type(pids) is list
1✔
271
        for pid in pids:
1✔
272
            if resource := cls.get_record_by_pid(pid):
1✔
273
                yield resource
1✔
274

275
    @classmethod
1✔
276
    def record_pid_exists(cls, pid):
1✔
277
        """Check if a persistent identifier exists.
278

279
        :param pid: The PID value.
280
        :returns: `True` if the PID exists.
281
        """
282
        assert cls.provider
1✔
283
        try:
1✔
284
            PersistentIdentifier.get(cls.provider.pid_type, pid)
1✔
285
            return True
1✔
286

287
        except NoResultFound, PIDDoesNotExistError:
1✔
288
            return False
1✔
289

290
    @classmethod
1✔
291
    def get_pid_by_id(cls, id):
1✔
292
        """Get pid by uuid."""
293
        persistent_identifier = cls.get_persistent_identifier(id)
1✔
294
        return str(persistent_identifier.pid_value)
1✔
295

296
    @classmethod
1✔
297
    def get_id_by_pid(cls, pid):
1✔
298
        """Get uuid by pid."""
299
        assert cls.provider
1✔
300
        try:
1✔
301
            persistent_identifier = PersistentIdentifier.get(cls.provider.pid_type, pid)
1✔
302
            return persistent_identifier.object_uuid
1✔
303
        except Exception:
×
304
            return None
×
305

306
    @classmethod
1✔
307
    def get_persistent_identifier(cls, id):
1✔
308
        """Get Persistent Identifier."""
309
        return PersistentIdentifier.get_by_object(cls.provider.pid_type, cls.object_type, id)
1✔
310

311
    @classmethod
1✔
312
    def _get_all(cls, with_deleted=False):
1✔
313
        """Get all persistent identifier records."""
314
        query = PersistentIdentifier.query.filter_by(pid_type=cls.provider.pid_type)
1✔
315
        if not with_deleted:
1✔
316
            query = query.filter_by(status=PIDStatus.REGISTERED)
1✔
317
        return query
1✔
318

319
    @classmethod
1✔
320
    def get_all_pids(cls, with_deleted=False, limit=100000):
1✔
321
        """Get all records pids. Return a generator iterator."""
322
        query = cls._get_all(with_deleted=with_deleted)
1✔
323
        if limit:
1✔
324
            count = query.count()
1✔
325
            # slower, less memory
326
            query = query.order_by(text("pid_value")).limit(limit)
1✔
327
            offset = 0
1✔
328
            while offset < count:
1✔
329
                for identifier in query.offset(offset):
1✔
330
                    yield identifier.pid_value
1✔
331
                offset += limit
1✔
332
        else:
333
            # faster, more memory
334
            for identifier in query:
1✔
335
                yield identifier.pid_value
1✔
336

337
    @classmethod
1✔
338
    def get_all_ids(cls, with_deleted=False, limit=100000):
1✔
339
        """Get all records uuids. Return a generator iterator."""
340
        query = cls._get_all(with_deleted=with_deleted)
1✔
341
        if limit:
1✔
342
            # slower, less memory
343
            query = query.order_by(text("pid_value")).limit(limit)
1✔
344
            offset = 0
1✔
345
            count = cls.count(with_deleted=with_deleted)
1✔
346
            while offset < count:
1✔
347
                for identifier in query.limit(limit).offset(offset):
1✔
348
                    yield identifier.object_uuid
1✔
349
                offset += limit
1✔
350
        else:
351
            # faster, more memory
352
            for identifier in query:
1✔
353
                yield identifier.object_uuid
1✔
354

355
    @classmethod
1✔
356
    def count(cls, with_deleted=False):
1✔
357
        """Get record count."""
358
        return cls._get_all(with_deleted=with_deleted).count()
1✔
359

360
    def db_record(self):
1✔
361
        """Get record stored into DB for this record (aka original_record).
362

363
        :returns: the record stored into database corresponding to the record
364
                  id. If the record ID doesn't yet exist, return None.
365
        """
366
        try:
1✔
367
            return self.__class__.get_record(self.id)
1✔
368
        except NoResultFound:
×
369
            pass
×
370

371
    def delete(self, force=False, dbcommit=False, delindex=False):
1✔
372
        """Delete record and persistent identifier."""
373
        can, _ = self.can_delete
1✔
374
        if can or force:
1✔
375
            if delindex:
1✔
376
                self.delete_from_index()
1✔
377
            persistent_identifier = self.get_persistent_identifier(self.id)
1✔
378
            persistent_identifier.delete()
1✔
379
            if force:
1✔
380
                db.session.delete(persistent_identifier)
1✔
381
            record = super().delete(force=force)
1✔
382
            if dbcommit:
1✔
383
                db.session.commit()
1✔
384
            return record
1✔
385
        raise IlsRecordError.NotDeleted()
1✔
386

387
    def update(self, data, commit=False, dbcommit=False, reindex=False):
1✔
388
        """Update data for record.
389

390
        :param data: a dict data to update the record.
391
        :param commit: if True push the db transaction.
392
        :param dbcommit: make the change effective in db.
393
        :param reindex: reindex the record.
394
        :returns: the modified record
395
        """
396
        if pid := data.get("pid"):
1✔
397
            db_record = self.get_record(self.id)
1✔
398
            if pid != db_record.pid:
1✔
399
                raise IlsRecordError.PidChange(f"{self.__class__.__name__} changed pid from {db_record.pid} to {pid}")
1✔
400
        record = self
1✔
401

402
        # Add schema if missing.
403
        if not data.get("$schema"):
1✔
404
            pid_type = self.provider.pid_type
1✔
405
            from .utils import get_schema_for_resource
1✔
406

407
            if schema := get_schema_for_resource(pid_type):
1✔
408
                data["$schema"] = schema
1✔
409

410
        super().update(data)
1✔
411
        if commit or dbcommit:
1✔
412
            self.commit()
1✔
413
        if dbcommit:
1✔
414
            self.dbcommit(reindex)
1✔
415
            record = self.get_record(self.id)
1✔
416
        return record
1✔
417

418
    def replace(self, data, commit=True, dbcommit=False, reindex=False):
1✔
419
        """Replace data in record."""
420
        new_data = deepcopy(data)
1✔
421
        pid = new_data.get("pid")
1✔
422
        if not pid:
1✔
423
            raise IlsRecordError.PidMissing(f"missing pid={self.pid}")
1✔
424
        self.clear()
1✔
425
        return self.update(new_data, commit=commit, dbcommit=dbcommit, reindex=reindex)
1✔
426

427
    def revert(self, revision_id, reindex=False):
1✔
428
        """Revert the record to a specific revision."""
429
        persistent_identifier = self.get_persistent_identifier(self.id)
1✔
430
        if persistent_identifier.is_deleted():
1✔
431
            raise IlsRecordError.Deleted()
1✔
432
        record = super().revert(revision_id=revision_id)
1✔
433
        if reindex:
1✔
NEW
434
            record.reindex(forceindex=False)
×
435
        return record
1✔
436

437
    def undelete(self, reindex=False):
1✔
438
        """Undelete the record."""
439
        persistent_identifier = self.get_persistent_identifier(self.id)
1✔
440
        if persistent_identifier.is_deleted():
1✔
441
            with db.session.begin_nested():
1✔
442
                persistent_identifier.status = PIDStatus.REGISTERED
1✔
443
                db.session.add(persistent_identifier)
1✔
444
        else:
445
            raise IlsRecordError.NotDeleted()
1✔
446

447
        return self.revert(self.revision_id - 2, reindex=reindex)
1✔
448

449
    def dbcommit(self, reindex=False, forceindex=False):
1✔
450
        """Commit changes to db."""
451
        db.session.commit()
1✔
452
        if reindex:
1✔
453
            self.reindex(forceindex=forceindex)
1✔
454
        return self
1✔
455

456
    def reindex(self, forceindex=False):
1✔
457
        """Reindex record."""
458
        indexer = self.get_indexer_class()
1✔
459
        if forceindex:
1✔
460
            return indexer(version_type="external_gte").index(self)
1✔
461
        return indexer().index(self)
1✔
462

463
    def delete_from_index(self):
1✔
464
        """Delete record from index."""
465
        indexer = self.get_indexer_class()
1✔
466
        try:
1✔
467
            indexer().delete(self)
1✔
468
        except NotFoundError:
1✔
469
            current_app.logger.warning(f"Cannot delete from index {self.__class__.__name__}: {self.pid}")
1✔
470
        except ValueError:
×
471
            current_app.logger.warning(f"Cannot delete from index {self.__class__.__name__}")
×
472

473
    @property
1✔
474
    def pid(self):
1✔
475
        """Get ils record pid value."""
476
        return self.get("pid")
1✔
477

478
    @property
1✔
479
    def persistent_identifier(self):
1✔
480
        """Get Persistent Identifier."""
481
        return self.get_persistent_identifier(self.id)
1✔
482

483
    def get_links_to_me(self, get_pids=False):
1✔
484
        """Get related resources linked to the record.
485

486
        :param get_pids: if `True` list of linked pids, otherwise count of
487
                         linked records.
488
        """
489
        return {}
1✔
490

491
    def reasons_not_to_delete(self):
1✔
492
        """Record deletion reasons."""
493
        return {}
1✔
494

495
    @property
1✔
496
    def can_delete(self):
1✔
497
        """Record can be deleted.
498

499
        :return a tuple with True|False and reasons not to delete if False.
500
        """
501
        reasons = self.reasons_not_to_delete()
1✔
502
        return len(reasons) == 0, reasons
1✔
503

504
    @property
1✔
505
    def organisation_pid(self):
1✔
506
        """Get organisation pid for circulation policy."""
507
        return extracted_data_from_ref(self.get("organisation"))
1✔
508

509
    @classmethod
1✔
510
    def get_metadata_identifier_names(cls):
1✔
511
        """Get metadata and identifier table names."""
512
        metadata = cls.model_cls.__tablename__
1✔
513
        identifier = cls.provider.identifier
1✔
514
        return metadata, identifier
1✔
515

516
    def replace_refs(self):
1✔
517
        """Replace the ``$ref`` keys within the JSON.
518

519
        Note: needed for jsonref>=1.0.0
520
        """
521
        return (type(self))(super().replace_refs())
1✔
522

523
    def resolve(self):
1✔
524
        """Resolve references data.
525

526
        Mainly used by the `resolve=1` URL parameter.
527

528
        :returns: a fresh copy of the resolved data.
529
        """
530
        return deepcopy(self.replace_refs())
1✔
531

532

533
class IlsRecordsIndexer(RecordIndexer):
1✔
534
    """Indexing class for ils."""
535

536
    record_cls = IlsRecord
1✔
537

538
    def index(self, record):
1✔
539
        """Indexing a record."""
540
        return super().index(record, arguments={"refresh": "true"})
1✔
541

542
    def delete(self, record):
1✔
543
        """Delete a record.
544

545
        :param record: Record instance.
546
        """
547
        return super().delete(record, refresh="true")
1✔
548

549
    def bulk_index(self, record_id_iterator, doc_type=None):
1✔
550
        """Bulk index records.
551

552
        :param record_id_iterator: Iterator yielding record UUIDs.
553
        """
554
        self._bulk_op(record_id_iterator, op_type="index", doc_type=doc_type)
1✔
555

556
    def process_bulk_queue(self, search_bulk_kwargs=None, stats_only=True):
1✔
557
        """Process bulk indexing queue.
558

559
        :param dict search_bulk_kwargs: Passed to
560
            :func:`elasticsearch:elasticsearch.helpers.bulk`.
561
        :param boolean stats_only: if `True` only report number of
562
            successful/failed operations instead of just number of
563
            successful and a list of error responses
564
        """
565
        with current_celery_app.pool.acquire(block=True) as conn:
1✔
566
            conn.ensure_connection(max_retries=current_app.config.get("CELERY_BROKER_CONNECTION_MAX_RETRIES", 10))
1✔
567
            consumer = Consumer(
1✔
568
                connection=conn,
569
                queue=self.mq_queue.name,
570
                exchange=self.mq_exchange.name,
571
                routing_key=self.mq_routing_key,
572
            )
573

574
            req_timeout = current_app.config["INDEXER_BULK_REQUEST_TIMEOUT"]
1✔
575

576
            search_bulk_kwargs = search_bulk_kwargs or {}
1✔
577

578
            count = bulk(
1✔
579
                self.client,
580
                self._actionsiter(consumer.iterqueue()),
581
                stats_only=stats_only,
582
                request_timeout=req_timeout,
583
                expand_action_callback=search.helpers.expand_action,
584
                **search_bulk_kwargs,
585
            )
586

587
            consumer.close()
1✔
588

589
        return self.mq_queue.name, count
1✔
590

591
    def _get_record_class(self, payload):
1✔
592
        """Get the record class from payload."""
593
        from .utils import get_record_class_from_schema_or_pid_type
1✔
594

595
        # take the first defined doc type for finding the class
596
        pid_type = payload.get("doc_type", "rec")
1✔
597
        return get_record_class_from_schema_or_pid_type(pid_type=pid_type)
1✔
598

599
    #
600
    # Low-level implementation
601
    #
602
    def _bulk_op(self, record_id_iterator, op_type, index=None, doc_type=None):
1✔
603
        """Index record in the search engine asynchronously.
604

605
        :param record_id_iterator: dIterator that yields record UUIDs.
606
        :param op_type: Indexing operation (one of ``index``, ``create``,
607
            ``delete`` or ``update``).
608
        :param index: The search engine index. (Default: ``None``)
609
        :param doc_type: The search index doc_type. (Default: ``None``)
610
        """
611
        with self.create_producer() as producer:
1✔
612
            for rec in record_id_iterator:
1✔
613
                data = {"id": str(rec), "op": op_type, "index": index, "doc_type": doc_type}
1✔
614
                producer.publish(
1✔
615
                    data,
616
                    declare=[self.mq_queue],
617
                    **self.mq_publish_kwargs,
618
                )
619

620
    def _actionsiter(self, message_iterator):
1✔
621
        """Iterate bulk actions.
622

623
        :param message_iterator: Iterator yielding messages from a queue.
624
        """
625
        for message in message_iterator:
1✔
626
            payload = message.decode()
1✔
627
            try:
1✔
628
                indexer = self._get_record_class(payload).get_indexer_class()
1✔
629
                if payload["op"] == "delete":
1✔
630
                    yield indexer()._delete_action(payload=payload)
×
631
                else:
632
                    yield indexer()._index_action(payload=payload)
1✔
633
                message.ack()
1✔
634
            except NoResultFound:
1✔
635
                message.reject()
×
636
            except Exception:
1✔
637
                message.reject()
1✔
638
                current_app.logger.exception(
1✔
639
                    "Failed to %s %s %s:%s",
640
                    payload["op"],
641
                    payload.get("doc_type", "rec"),
642
                    payload.get("pid"),
643
                    payload.get("id"),
644
                )
645

646
    def _index_action(self, payload):
1✔
647
        """Bulk index action.
648

649
        :param payload: Decoded message body.
650
        :return: Dictionary defining a search index bulk 'index' action.
651
        """
652
        record = self.record_cls.get_record(payload["id"])
1✔
653
        index = self.record_to_index(record)
1✔
654

655
        arguments = {}
1✔
656
        index = payload.get("index") or index
1✔
657
        body = self._prepare_record(record, index, arguments)
1✔
658
        action = {
1✔
659
            "_op_type": "index",
660
            "_index": index,
661
            "_id": str(record.id),
662
            "_version": record.revision_id,
663
            "_version_type": self._version_type,
664
            "_source": body,
665
        }
666
        action.update(arguments)
1✔
667

668
        return action
1✔
669

670
    def _prepare_record(self, record, index, arguments=None, **kwargs):
1✔
671
        """Prepare record data for indexing.
672

673
        :param record: The record to prepare.
674
        :param index: The search index.
675
        :param arguments: The arguments to send to search index upon indexing.
676
        :param **kwargs: Extra parameters.
677
        :return: The record metadata.
678
        """
679
        if not getattr(record, "enable_jsonref", True):
1✔
680
            # If dumper is None, dumps() will use the default configured dumper
681
            # on the Record class.
682
            data = record.dumps(dumper=self.record_dumper)
1✔
683
        else:
684
            # Old-style dumping - the old style will still if
685
            # INDEXER_REPLACE_REFS is False use the Record.dumps(), however the
686
            # default implementation is backward compatible for new-style
687
            # records. Also, we're adding extra information into the record
688
            # like _created and _updated afterwards, which the Record.dumps()
689
            # have no control over.
690
            # Original code
691
            # data = copy.deepcopy(record.replace_refs())
692
            data = record.replace_refs().dumps() if current_app.config.get("INDEXER_REPLACE_REFS") else record.dumps()
1✔
693

694
        data["_created"] = record.created.isoformat() if record.created else None
1✔
695
        data["_updated"] = record.updated.isoformat() if record.updated else None
1✔
696

697
        # Allow modification of data prior to sending to search index.
698
        before_record_index.send(
1✔
699
            current_app._get_current_object(),
700
            json=data,
701
            record=record,
702
            index=index,
703
            arguments={} if arguments is None else arguments,
704
            **kwargs,
705
        )
706
        return data
1✔
707

708

709
class ReferencedRecordsIndexer:
1✔
710
    """Referenced records Indexer class."""
711

712
    def index(self, indexer_class, referenced):
1✔
713
        """Index record.
714

715
        :param indexer_class: record indexer class.
716
        :param referenced: referenced records to index. A list of dicts
717
            containing `pid_type` and `record` keys of the records that will
718
            be indexed.
719
        """
720
        indexer = indexer_class()
1✔
721
        for r in referenced:
1✔
722
            try:
1✔
723
                record_to_index = r["record"]
1✔
724
                indexer.index(record_to_index)
1✔
725
            except Exception as err:
×
726
                current_app.logger.error(f"Record indexing error {r['pid_type']} {r['record']['pid']}: {err!s}")
×
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