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

rero / rero-mef / 16621609190

30 Jul 2025 11:43AM UTC coverage: 84.491% (+0.008%) from 84.483%
16621609190

push

github

rerowep
chore: update dependencies

Co-Authored-by: Peter Weber <peter.weber@rero.ch>

4560 of 5397 relevant lines covered (84.49%)

0.84 hits per line

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

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

16
# Copyright (C) 2018 RERO.
17
#
18
# RERO Ebooks is free software; you can redistribute it and/or modify it
19
# under the terms of the MIT License; see LICENSE file for more details.
20

21
"""Utilities."""
22

23
import gc
1✔
24
import hashlib
1✔
25
import json
1✔
26
import os
1✔
27
import time
1✔
28
from copy import deepcopy
1✔
29
from datetime import datetime, timedelta, timezone
1✔
30
from io import StringIO
1✔
31
from json import JSONDecodeError, JSONDecoder, dumps
1✔
32
from time import sleep
1✔
33
from uuid import uuid4
1✔
34

35
import click
1✔
36
import ijson
1✔
37
import psycopg2
1✔
38
import requests
1✔
39
import sqlalchemy
1✔
40
from dateutil import parser
1✔
41
from flask import current_app
1✔
42
from invenio_cache.proxies import current_cache
1✔
43
from invenio_db import db
1✔
44
from invenio_oaiharvester.api import get_info_by_oai_name
1✔
45
from invenio_oaiharvester.errors import (
1✔
46
    InvenioOAIHarvesterConfigNotFound,
47
    WrongDateCombination,
48
)
49
from invenio_oaiharvester.models import OAIHarvestConfig
1✔
50
from invenio_oaiharvester.utils import get_oaiharvest_object
1✔
51
from invenio_pidstore.errors import PIDDoesNotExistError
1✔
52
from invenio_pidstore.models import PersistentIdentifier
1✔
53
from invenio_records_rest.utils import obj_or_import_string
1✔
54
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
1✔
55
from pymarc.marcxml import parse_xml_to_array
1✔
56
from requests.adapters import HTTPAdapter
1✔
57
from requests.packages.urllib3.util.retry import Retry
1✔
58
from sickle import OAIResponse, Sickle, oaiexceptions
1✔
59
from sickle.iterator import OAIItemIterator
1✔
60
from sickle.oaiexceptions import NoRecordsMatch
1✔
61

62
# Hours can not be retrieved by get_info_by_oai_name
63
# TIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
64
TIME_FORMAT = "%Y-%m-%d"
1✔
65

66

67
class SickleWithRetries(Sickle):
1✔
68
    """Sickle class for OAI harvesting."""
69

70
    def harvest(self, **kwargs):  # pragma: no cover
71
        """Make HTTP requests to the OAI server.
72

73
        :param kwargs: OAI HTTP parameters.
74
        :rtype: :class:`sickle.OAIResponse`
75
        """
76
        http_response = self._request(kwargs)
77
        for _ in range(self.max_retries):
78
            if (
79
                self._is_error_code(http_response.status_code)
80
                and http_response.status_code in self.retry_status_codes
81
            ):
82
                retry_after = self.get_retry_after(http_response)
83
                current_app.logger.warning(
84
                    f"HTTP {http_response.status_code}! "
85
                    f"Retrying after {retry_after} seconds..."
86
                )
87
                time.sleep(retry_after)
88
                http_response = self._request(kwargs)
89
        http_response.raise_for_status()
90
        if self.encoding:
91
            http_response.encoding = self.encoding
92
        return OAIResponse(http_response, params=kwargs)
93

94

95
def add_oai_source(
1✔
96
    name, baseurl, metadataprefix="marc21", setspecs="", comment="", update=False
97
):
98
    """Add OAIHarvestConfig."""
99
    with current_app.app_context():
1✔
100
        source = OAIHarvestConfig.query.filter_by(name=name).first()
1✔
101
        if not source:
1✔
102
            source = OAIHarvestConfig(
1✔
103
                name=name,
104
                baseurl=baseurl,
105
                metadataprefix=metadataprefix,
106
                setspecs=setspecs,
107
                comment=comment,
108
            )
109
            source.save()
1✔
110
            db.session.commit()
1✔
111
            return "Added"
1✔
112
        if update:
1✔
113
            source.name = name
1✔
114
            source.baseurl = baseurl
1✔
115
            source.metadataprefix = metadataprefix
1✔
116
            if setspecs != "":
1✔
117
                source.setspecs = setspecs
1✔
118
            if comment != "":
1✔
119
                source.comment = comment
1✔
120
            db.session.commit()
1✔
121
            return "Updated"
1✔
122
        return "Not Updated"
1✔
123

124

125
def oai_get_last_run(name, verbose=False):
1✔
126
    """Gets the lastrun for a OAI harvest configuration.
127

128
    :param name: name of the OAI harvest configuration.
129
    :return: datetime of last OAI harvest run.
130
    """
131
    try:
1✔
132
        oai_source = get_oaiharvest_object(name)
1✔
133
        lastrun_date = oai_source.lastrun
1✔
134
        if verbose:
1✔
135
            click.echo(f"OAI {name}: last run: {lastrun_date}")
1✔
136
        return lastrun_date
1✔
137
    except InvenioOAIHarvesterConfigNotFound:
1✔
138
        if verbose:
1✔
139
            click.echo(f"ERROR OAI config not found: {name}")
1✔
140
        return None
1✔
141

142

143
def oai_set_last_run(name, date, verbose=False):
1✔
144
    """Sets the lastrun for a OAI harvest configuration.
145

146
    :param name: name of the OAI harvest configuration.
147
    :param date: Date to set as last run
148
    :return: datetime of date to set.
149
    """
150
    try:
1✔
151
        oai_source = get_oaiharvest_object(name)
1✔
152
        lastrun_date = date
1✔
153
        if isinstance(date, str):
1✔
154
            lastrun_date = parser.isoparse(date)
1✔
155
        oai_source.update_lastrun(lastrun_date)
1✔
156
        oai_source.save()
1✔
157
        db.session.commit()
1✔
158
        if verbose:
1✔
159
            click.echo(f"OAI {name}: set last run: {lastrun_date}")
1✔
160
        return lastrun_date
1✔
161
    except InvenioOAIHarvesterConfigNotFound:
1✔
162
        if verbose:
1✔
163
            click.echo(f"ERROR OAI config not found: {name}")
1✔
164
    except ValueError as err:
1✔
165
        if verbose:
1✔
166
            click.echo(f"OAI set lastrun {name}: {err}")
1✔
167
    return None
1✔
168

169

170
class MyOAIItemIterator(OAIItemIterator):
1✔
171
    """OAI item iterator with accessToken."""
172

173
    def next_resumption_token_and_items(self):
1✔
174
        """Get next resumtion token and items."""
175
        self.resumption_token = self._get_resumption_token()
1✔
176
        self._items = self.oai_response.xml.iterfind(
1✔
177
            f".//{self.sickle.oai_namespace}{self.element}"
178
        )
179

180
    def _next_response(self):
1✔
181
        """Get the next response from the OAI server."""
182
        params = self.params
1✔
183
        access_token = params.get("accessToken")
1✔
184
        if self.resumption_token:
1✔
185
            params = {"resumptionToken": self.resumption_token.token, "verb": self.verb}
×
186
        if access_token:
1✔
187
            params["accessToken"] = access_token
×
188

189
        count = 0
1✔
190
        while count < 5:
1✔
191
            try:
1✔
192
                self.oai_response = self.sickle.harvest(**params)
1✔
193
                self.oai_response.xml  # try to get xml
1✔
194
                count = 5
1✔
195
            except Exception as err:
×
196
                count += 1
×
197
                current_app.logger.error(f"Sickle harvest {count} {err}")
×
198
                sleep(60)
×
199
        error = self.oai_response.xml.find(f".//{self.sickle.oai_namespace}error")
1✔
200
        if error is not None:
1✔
201
            code = error.attrib.get("code", "UNKNOWN")
×
202
            description = error.text or ""
×
203
            try:
×
204
                raise getattr(oaiexceptions, code[0].upper() + code[1:])(description)
×
205
            except AttributeError:
×
206
                raise oaiexceptions.OAIError(description)
×
207
        if self.resumption_token:
1✔
208
            # Test we got a complete response ('resumptionToken' in xml)
209
            resumption_token_element = self.oai_response.xml.find(
×
210
                f".//{self.sickle.oai_namespace}resumptionToken"
211
            )
212

213
            if resumption_token_element is None:
×
214
                current_app.logger.error(
×
215
                    f"ERROR HARVESTING incomplete response: "
216
                    f"{self.resumption_token.cursor} "
217
                    f"{self.resumption_token.token}"
218
                )
219
                sleep(60)
×
220
            else:
221
                self.next_resumption_token_and_items()
×
222
        else:
223
            # first time
224
            self.next_resumption_token_and_items()
1✔
225

226

227
def oai_process_records_from_dates(
1✔
228
    name,
229
    sickle,
230
    oai_item_iterator,
231
    transformation,
232
    record_class,
233
    max_retries=0,
234
    access_token=None,
235
    days_span=30,
236
    from_date=None,
237
    until_date=None,
238
    ignore_deleted=False,
239
    dbcommit=True,
240
    reindex=True,
241
    test_md5=True,
242
    verbose=False,
243
    debug=False,
244
    **kwargs,
245
):
246
    """Harvest multiple records from an OAI repo.
247

248
    :param name: The name of the OAIHarvestConfig to use instead of passing
249
                 specific parameters.
250
    :param from_date: The lower bound date for the harvesting (optional).
251
    :param until_date: The upper bound date for the harvesting (optional).
252
    """
253
    from rero_mef.api import Action
1✔
254

255
    # data on IDREF Servers starts on 2000-10-01
256
    url, metadata_prefix, last_run, setspecs = get_info_by_oai_name(name)
1✔
257

258
    request = sickle(url, iterator=oai_item_iterator, max_retries=max_retries)
1✔
259

260
    update_last_run = from_date is None and until_date is None
1✔
261
    dates_initial = {
1✔
262
        "from": from_date or last_run,
263
        "until": until_date or datetime.now().strftime(TIME_FORMAT),
264
    }
265
    # Sanity check
266
    if (
1✔
267
        dates_initial["until"] is not None
268
        and dates_initial["from"] > dates_initial["until"]
269
    ):
270
        raise WrongDateCombination("'Until' date larger than 'from' date.")
×
271

272
    # If we don't have specifications for set searches the setspecs will be
273
    # set to e list with None to go into the retrieval loop without
274
    # a set definition (line 177)
275
    setspecs = setspecs.split() or [None]
1✔
276
    count = 0
1✔
277
    action_count = {}
1✔
278
    mef_action_count = {}
1✔
279
    for spec in setspecs:
1✔
280
        dates = dates_initial
1✔
281
        params = {"metadataPrefix": metadata_prefix, "ignore_deleted": ignore_deleted}
1✔
282
        if access_token:
1✔
283
            params["accessToken"] = access_token
×
284
        if spec:
1✔
285
            params["set"] = spec
1✔
286

287
        from_date = parser.isoparse(dates_initial["from"])
1✔
288
        real_until_date = parser.isoparse(f"{dates_initial['until']} 23:59:59.999999")
1✔
289
        while from_date < real_until_date:
1✔
290
            until_date = from_date + timedelta(days=days_span)
1✔
291
            until_date = min(until_date, real_until_date)
1✔
292
            dates = {
1✔
293
                "from": from_date.strftime(TIME_FORMAT),
294
                "until": until_date.strftime(TIME_FORMAT),
295
            }
296
            params |= dates
1✔
297
            if verbose:
1✔
298
                click.secho(
1✔
299
                    f"OAI {name} spec({spec}): {dates['from']} .. {dates['until']}",
300
                    fg="cyan",
301
                )
302
            try:
1✔
303
                for idx, record in enumerate(request.ListRecords(**params), 1):
1✔
304
                    records = parse_xml_to_array(StringIO(record.raw))
1✔
305
                    rec = None
1✔
306
                    try:
1✔
307
                        try:
1✔
308
                            updated = datetime.strptime(
1✔
309
                                records[0]["005"].data, "%Y%m%d%H%M%S.%f"
310
                            )
311
                        except Exception:
×
312
                            updated = "????"
×
313
                        if rec := transformation(
1✔
314
                            records[0], logger=current_app.logger
315
                        ).json:
316
                            if msg := rec.get("NO TRANSFORMATION"):
1✔
317
                                if verbose:
×
318
                                    click.secho(
×
319
                                        f"OAI {name} spec({spec}): "
320
                                        f"{idx} {rec.get('pid', '???')}"
321
                                        f"NO TRANSFORMATION: {msg}",
322
                                        fg="yellow",
323
                                    )
324
                            else:
325
                                pid = rec.get("pid")
1✔
326
                                record, action = record_class.create_or_update(
1✔
327
                                    data=rec,
328
                                    dbcommit=True,
329
                                    reindex=True,
330
                                    test_md5=test_md5,
331
                                )
332
                                count += 1
1✔
333
                                action_count.setdefault(action, 0)
1✔
334
                                action_count[action] += 1
1✔
335
                                m_actions = {}
1✔
336
                                if action in [
1✔
337
                                    Action.CREATE,
338
                                    Action.UPDATE,
339
                                    Action.REPLACE,
340
                                ]:
341
                                    m_record, m_actions = record.create_or_update_mef(
1✔
342
                                        dbcommit=True, reindex=True
343
                                    )
344
                                    for m_action in m_actions.values():
1✔
345
                                        mef_action_count.setdefault(m_action, 0)
1✔
346
                                        mef_action_count[m_action] += 1
1✔
347

348
                                else:
349
                                    m_action = Action.UPTODATE
1✔
350
                                    m_record = {}
1✔
351
                                    mef_action_count.setdefault(m_action, 0)
1✔
352
                                    mef_action_count[m_action] += 1
1✔
353

354
                                if verbose:
1✔
355
                                    msg = (
1✔
356
                                        f"OAI {name} spec({spec}): {pid}"
357
                                        f" updated: {updated} {action.value}"
358
                                    )
359
                                    for mef_pid, m_action in m_actions.items():
1✔
360
                                        msg = f"{msg} | mef: {mef_pid} {m_action.value}"
1✔
361
                                    if viaf_pid := m_record.get("viaf_pid"):
1✔
362
                                        msg = f"{msg} | viaf: {viaf_pid}"
×
363
                                    click.echo(msg)
1✔
364
                        elif verbose:
×
365
                            click.secho(
×
366
                                f"OAI {name} spec({spec}): {idx}"
367
                                f"NO TRANSFORMATION:"
368
                                f"\n{records[0]}",
369
                                fg="yellow",
370
                            )
371
                    except Exception as err:
×
372
                        msg = f"Creating {name} {idx}: {err} {record}"
×
373
                        if rec:
×
374
                            msg = f"{msg}\n{rec}"
×
375
                        current_app.logger.error(msg, exc_info=True, stack_info=True)
×
376
            except NoRecordsMatch:
×
377
                # get the next from to until dates
378
                from_date = until_date
×
379
                continue
×
380
            except Exception as err:
×
381
                current_app.logger.error(err, exc_info=True, stack_info=True)
×
382
                count = -1
×
383
            # get the next from to until dates
384
            from_date = until_date
1✔
385
    if update_last_run:
1✔
386
        oai_set_last_run(name=name, date=dates_initial["until"], verbose=verbose)
1✔
387
    return count, action_count, mef_action_count
1✔
388

389

390
def oai_save_records_from_dates(
1✔
391
    name,
392
    file_name,
393
    sickle,
394
    oai_item_iterator,
395
    max_retries=0,
396
    access_token=None,
397
    days_span=30,
398
    from_date=None,
399
    until_date=None,
400
    verbose=False,
401
    **kwargs,
402
):
403
    """Harvest and save multiple records from an OAI repo.
404

405
    :param name: The name of the OAIHarvestConfig to use instead of passing
406
                 specific parameters.
407
    :param from_date: The lower bound date for the harvesting (optional).
408
    :param until_date: The upper bound date for the harvesting (optional).
409
    """
410
    url, metadata_prefix, last_run, setspecs = get_info_by_oai_name(name)
1✔
411

412
    request = sickle(url, iterator=oai_item_iterator, max_retries=max_retries)
1✔
413

414
    dates_initial = {
1✔
415
        "from": from_date or last_run,
416
        "until": until_date or datetime.now().strftime(TIME_FORMAT),
417
    }
418
    # Sanity check
419
    if dates_initial["from"] > dates_initial["until"]:
1✔
420
        raise WrongDateCombination("'Until' date larger than 'from' date.")
×
421

422
    # If we don't have specifications for set searches the setspecs will be
423
    # set to e list with None to go into the retrieval loop without
424
    # a set definition (line 177)
425
    setspecs = setspecs.split() or [None]
1✔
426
    count = 0
1✔
427
    with open(file_name, "bw") as output_file:
1✔
428
        for spec in setspecs:
1✔
429
            params = {"metadataPrefix": metadata_prefix, "ignore_deleted": False}
1✔
430
            if access_token:
1✔
431
                params["accessToken"] = access_token
×
432
            if spec:
1✔
433
                params["set"] = spec
1✔
434

435
            from_date = parser.isoparse(dates_initial["from"])
1✔
436
            real_until_date = parser.isoparse(
1✔
437
                f"{dates_initial['until']} 23:59:59.999999"
438
            )
439
            while from_date < real_until_date:
1✔
440
                until_date = from_date + timedelta(days=days_span)
1✔
441
                until_date = min(until_date, real_until_date)
1✔
442
                dates = {
1✔
443
                    "from": from_date.strftime(TIME_FORMAT),
444
                    "until": until_date.strftime(TIME_FORMAT),
445
                }
446
                if verbose:
1✔
447
                    click.secho(
×
448
                        f"OAI {name} spec({spec}): {dates['from']} .. {dates['until']}",
449
                        fg="cyan",
450
                    )
451
                params |= dates
1✔
452
                try:
1✔
453
                    for record in request.ListRecords(**params):
1✔
454
                        count += 1
1✔
455
                        records = parse_xml_to_array(StringIO(record.raw))
1✔
456
                        rec = records[0]
1✔
457
                        if verbose:
1✔
458
                            click.echo(
×
459
                                f"OAI {name} spec({spec}): "
460
                                f"{from_date.strftime(TIME_FORMAT)} "
461
                                f"count:{count:>10} = {rec['001'].data}"
462
                            )
463
                        rec.leader = f"{rec.leader[:9]}a{rec.leader[10:]}"
1✔
464
                        output_file.write(rec.as_marc())
1✔
465
                except NoRecordsMatch:
×
466
                    from_date = until_date
×
467
                    continue
×
468
                except Exception as err:
×
469
                    current_app.logger.error(err)
×
470
                from_date = until_date
1✔
471
    if verbose:
1✔
472
        click.echo(f"OAI {name}: {count}")
×
473
    return count
1✔
474

475

476
def oai_get_record(
1✔
477
    id_, name, transformation, access_token=None, identifier=None, debug=False, **kwargs
478
):
479
    """Get record from an OAI repo.
480

481
    :param identifier: identifier of record.
482
    """
483
    url, metadata_prefix, lastrun, setspecs = get_info_by_oai_name(name)
1✔
484

485
    request = Sickle(
1✔
486
        endpoint=url,
487
        max_retries=5,
488
        default_retry_after=10,
489
        retry_status_codes=[423, 503],
490
    )
491

492
    params = {"metadataPrefix": metadata_prefix, "identifier": f"{identifier}{id_}"}
1✔
493
    full_url = f"{url}?verb=GetRecord&metadataPrefix={metadata_prefix}"
1✔
494
    full_url = f"{full_url}&identifier={identifier}{id_}"
1✔
495

496
    if access_token:
1✔
497
        params["accessToken"] = access_token
×
498
        full_url = f"{full_url}&accessToken={access_token}"
×
499

500
    try:
1✔
501
        record = request.GetRecord(**params)
1✔
502
        msg = f"OAI-{name:<12} get: {id_:<15} {full_url} | OK"
×
503
    except Exception:
1✔
504
        msg = f"OAI-{name:<12} get: {id_:<15} {full_url} | NO RECORD"
1✔
505
        if debug:
1✔
506
            raise
×
507
        return None, msg
1✔
508
    records = parse_xml_to_array(StringIO(record.raw))
×
509
    if debug:
×
510
        from rero_mef.marctojson.helper import display_record
×
511

512
        display_record(records[0])
×
513
    trans_record = transformation(records[0], logger=current_app.logger).json
×
514
    return trans_record, msg
×
515

516

517
def read_json_record(json_file, buf_size=1024, decoder=JSONDecoder()):
1✔
518
    """Read lasy JSON records from file.
519

520
    :param json_file: JSON file handle
521
    :param buf_size: buffer size for file read
522
    :param decoder: decoder to use for decoding
523
    :return: record Generator
524
    """
525
    buffer = json_file.read(2).replace("\n", "")
1✔
526
    # we have to delete the first [ for an list of records
527
    if buffer.startswith("["):
1✔
528
        buffer = buffer[1:].lstrip()
1✔
529
    while True:
1✔
530
        block = json_file.read(buf_size)
1✔
531
        if not block:
1✔
532
            break
1✔
533
        buffer += block.replace("\n", "")
1✔
534
        pos = 0
1✔
535
        while True:
1✔
536
            try:
1✔
537
                buffer = buffer.lstrip()
1✔
538
                obj, pos = decoder.raw_decode(buffer)
1✔
539
            except JSONDecodeError:
1✔
540
                break
1✔
541
            else:
542
                yield obj
1✔
543
                buffer = buffer[pos:].lstrip()
1✔
544

545
                if len(buffer) <= 0:
1✔
546
                    # buffer is empty read more data
547
                    buffer = json_file.read(buf_size)
×
548
                if buffer.startswith(","):
1✔
549
                    # delete records deliminators
550
                    buffer = buffer[1:].lstrip()
1✔
551

552

553
def export_json_records(
1✔
554
    pids, pid_type, output_file_name, indent=2, schema=True, verbose=False
555
):
556
    """Writes records from record_class to file.
557

558
    :param pids: pids to use
559
    :param pid_type: pid_type to use
560
    :param output_file_name: file name to write to
561
    :param indent: indent to use in output file
562
    :param schema: do not delete $schema
563
    :param verbose: verbose print
564
    :returns: count of records written
565
    """
566
    record_class = get_entity_class(pid_type)
1✔
567
    count = 0
1✔
568
    outfile = JsonWriter(output_file_name, indent=indent)
1✔
569
    for pid in pids:
1✔
570
        try:
1✔
571
            rec = record_class.get_record_by_pid(pid)
1✔
572
            count += 1
1✔
573
            if verbose:
1✔
574
                click.echo(f"{count: <8} {pid_type} export {rec.pid}:{rec.id}")
×
575
            if not schema:
1✔
576
                rec.pop("$schema", None)
1✔
577
                for source in ("idref", "gnd", "rero"):
1✔
578
                    if source in rec and isinstance(source, dict):
1✔
579
                        rec[source].pop("$schema", None)
×
580
            outfile.write(rec)
1✔
581
        except Exception as err:
×
582
            click.echo(err)
×
583
            click.echo(f"ERROR: Can not export pid:{pid}")
×
584

585

586
def number_records_in_file(json_file, file_type):
1✔
587
    """Get number of records per file."""
588
    count = 0
1✔
589
    with open(json_file, buffering=1) as file:
1✔
590
        for line in file:
1✔
591
            if (file_type == "json" and '"pid"' in line) or file_type == "csv":
1✔
592
                count += 1
1✔
593
    return count
1✔
594

595

596
def progressbar(items, length=0, verbose=False, label=""):
1✔
597
    """Verbose progress bar."""
598
    if verbose:
1✔
599
        label = f"{label} ({length})" if label else str(length)
×
600
        with click.progressbar(
×
601
            items,
602
            label=label,
603
            length=length,
604
        ) as progressbar_items:
605
            yield from progressbar_items
×
606
    else:
607
        yield from items
1✔
608

609

610
def get_host():
1✔
611
    """Get the host from the config."""
612
    # from flask import current_app
613
    # with current_app.app_context():
614
    #     return current_app.config.get('JSONSCHEMAS_HOST')
615
    return "mef.rero.ch"
1✔
616

617

618
def resolve_record(path, object_class):
1✔
619
    """Resolve local records.
620

621
    :param path: pid for record
622
    :object_class: record class to use
623
    :returns: record for pid or {}
624
    """
625
    try:
1✔
626
        return object_class.get_record_by_pid(path)
1✔
627
    except PIDDoesNotExistError:
×
628
        return {}
×
629

630

631
def metadata_csv_line(record, record_uuid, date):
1✔
632
    """Build CSV metadata table line."""
633
    created_date = updated_date = date
1✔
634
    sep = "\t"
1✔
635
    metadata = (
1✔
636
        created_date,
637
        updated_date,
638
        record_uuid,
639
        json.dumps(record).replace("\\", "\\\\"),
640
        "1",
641
    )
642
    metadata_line = sep.join(metadata)
1✔
643
    return metadata_line + os.linesep
1✔
644

645

646
def pidstore_csv_line(entity, entity_pid, record_uuid, date):
1✔
647
    """Build CSV pidstore table line."""
648
    created_date = updated_date = date
1✔
649
    sep = "\t"
1✔
650
    pidstore_data = [
1✔
651
        created_date,
652
        updated_date,
653
        entity,
654
        entity_pid,
655
        "R",
656
        "rec",
657
        record_uuid,
658
    ]
659
    pidstore_line = sep.join(pidstore_data)
1✔
660
    return pidstore_line + os.linesep
1✔
661

662

663
def raw_connection():
1✔
664
    """Return a raw connection to the database."""
665
    with current_app.app_context():
×
666
        uri = current_app.config.get("SQLALCHEMY_DATABASE_URI")
×
667
        engine = sqlalchemy.create_engine(uri)
×
668
        # conn = engine.connect()
669
        connection = engine.raw_connection()
×
670
        connection.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
×
671
        return connection
×
672

673

674
def db_copy_from(buffer, table, columns):
1✔
675
    """Copy data from file to db."""
676
    connection = raw_connection()
×
677
    cursor = connection.cursor()
×
678
    try:
×
679
        cursor.copy_from(file=buffer, table=table, columns=columns, sep="\t")
×
680
        connection.commit()
×
681
    except psycopg2.DataError as err:
×
682
        current_app.logger.error(f"data load error: {err}")
×
683
    # cursor.execute(f'VACUUM ANALYSE {table}')
684
    # cursor.close()
685
    connection.close()
×
686

687

688
def db_copy_to(filehandle, table, columns):
1✔
689
    """Copy data from db to file."""
690
    connection = raw_connection()
×
691
    cursor = connection.cursor()
×
692
    try:
×
693
        cursor.copy_to(file=filehandle, table=table, columns=columns, sep="\t")
×
694
        cursor.connection.commit()
×
695
    except psycopg2.DataError as err:
×
696
        current_app.logger.error(f"data load error: {err}")
×
697
    cursor.execute(f"VACUUM ANALYSE {table}")
×
698
    cursor.close()
×
699
    connection.close()
×
700

701

702
def bulk_index(entity, uuids, verbose=False):
1✔
703
    """Bulk index records."""
704
    retry = True
×
705
    minutes = 1
×
706
    indexer_class = get_entity_indexer_class(entity)()
×
707
    while retry:
×
708
        try:
×
709
            indexer_class.bulk_index(uuids)
×
710
            res = indexer_class.process_bulk_queue()
×
711
            if verbose:
×
712
                click.echo(f" bulk indexed: {entity} {res}")
×
713
            retry = False
×
714
        except Exception as exc:
×
715
            msg = f"Bulk Index Error: retry in {minutes} min {exc}"
×
716
            current_app.logger.error(msg)
×
717
            if verbose:
×
718
                click.secho(msg, fg="red")
×
719
            sleep(minutes * 60)
×
720
            retry = True
×
721
            minutes *= 2
×
722

723

724
def bulk_load_entity(
1✔
725
    entity, data, table, columns, bulk_count=0, verbose=False, reindex=False
726
):
727
    """Bulk load entity data to table."""
728
    if bulk_count <= 0:
×
729
        bulk_count = current_app.config.get("BULK_CHUNK_COUNT", 100000)
×
730
    count = 0
×
731
    buffer = StringIO()
×
732
    buffer_uuid = []
×
733
    index = columns.index("id") if "id" in columns else -1
×
734
    start_time = datetime.now(timezone.utc)
×
735
    with open(data, encoding="utf-8", buffering=1) as input_file:
×
736
        for line in input_file:
×
737
            count += 1
×
738
            buffer.write(line)
×
739
            if index >= 0 and reindex:
×
740
                buffer_uuid.append(line.split("\t")[index])
×
741
            if count % bulk_count == 0:
×
742
                buffer.flush()
×
743
                buffer.seek(0)
×
744
                if verbose:
×
745
                    end_time = datetime.now(timezone.utc)
×
746
                    diff_time = end_time - start_time
×
747
                    start_time = end_time
×
748
                    click.echo(
×
749
                        f"{entity} copy from file: {count} {diff_time.seconds}s",
750
                        nl=False,
751
                    )
752
                db_copy_from(buffer=buffer, table=table, columns=columns)
×
753
                buffer.close()
×
754

755
                if index >= 0 and reindex:
×
756
                    bulk_index(entity=entity, uuids=buffer_uuid, verbose=verbose)
×
757
                    buffer_uuid.clear()
×
758
                elif verbose:
×
759
                    click.echo()
×
760

761
                # force the Garbage Collector to release unreferenced memory
762
                # gc.collect()
763
                # new buffer
764
                buffer = StringIO()
×
765

766
        if verbose:
×
767
            end_time = datetime.now(timezone.utc)
×
768
            diff_time = end_time - start_time
×
769
            click.echo(
×
770
                f"{entity} copy from file: {count} {diff_time.seconds}s", nl=False
771
            )
772
        buffer.flush()
×
773
        buffer.seek(0)
×
774
        db_copy_from(buffer=buffer, table=table, columns=columns)
×
775
        buffer.close()
×
776
        if index >= 0 and reindex:
×
777
            bulk_index(entity=entity, uuids=buffer_uuid, verbose=verbose)
×
778
            buffer_uuid.clear()
×
779
        elif verbose:
×
780
            click.echo()
×
781

782
    # force the Garbage Collector to release unreferenced memory
783
    gc.collect()
×
784

785

786
def bulk_load_metadata(entity, metadata, bulk_count=0, verbose=True, reindex=False):
1✔
787
    """Bulk load entity data to metadata table."""
788
    entity_class = get_entity_class(entity)
×
789
    table, identifier = entity_class.get_metadata_identifier_names()
×
790
    columns = ("created", "updated", "id", "json", "version_id")
×
791
    bulk_load_entity(
×
792
        entity=entity,
793
        data=metadata,
794
        table=table,
795
        columns=columns,
796
        bulk_count=bulk_count,
797
        verbose=verbose,
798
        reindex=reindex,
799
    )
800

801

802
def bulk_load_pids(entity, pidstore, bulk_count=0, verbose=True, reindex=False):
1✔
803
    """Bulk load entity data to metadata table."""
804
    table = "pidstore_pid"
×
805
    columns = (
×
806
        "created",
807
        "updated",
808
        "pid_type",
809
        "pid_value",
810
        "status",
811
        "object_type",
812
        "object_uuid",
813
    )
814
    bulk_load_entity(
×
815
        entity=entity,
816
        data=pidstore,
817
        table=table,
818
        columns=columns,
819
        bulk_count=bulk_count,
820
        verbose=verbose,
821
        reindex=reindex,
822
    )
823

824

825
def bulk_load_ids(entity, ids, bulk_count=0, verbose=True, reindex=False):
1✔
826
    """Bulk load entity data to id table."""
827
    entity_class = get_entity_class(entity)
×
828
    metadata, identifier = entity_class.get_metadata_identifier_names()
×
829
    columns = ("recid",)
×
830
    bulk_load_entity(
×
831
        entity=entity,
832
        data=ids,
833
        table=identifier,
834
        columns=columns,
835
        bulk_count=bulk_count,
836
        verbose=verbose,
837
        reindex=reindex,
838
    )
839

840

841
def bulk_save_entity(file_name, table, columns, verbose=False):
1✔
842
    """Bulk save entity data to file."""
843
    with open(file_name, "w", encoding="utf-8") as output_file:
×
844
        db_copy_to(filehandle=output_file, table=table, columns=columns)
×
845

846

847
def bulk_save_metadata(entity, file_name, verbose=False):
1✔
848
    """Bulk save entity data from metadata table."""
849
    if verbose:
×
850
        click.echo(f"{entity} save to file: {file_name}")
×
851
    entity_class = get_entity_class(entity)
×
852
    metadata, identifier = entity_class.get_metadata_identifier_names()
×
853
    columns = ("created", "updated", "id", "json", "version_id")
×
854
    bulk_save_entity(
×
855
        file_name=file_name, table=metadata, columns=columns, verbose=verbose
856
    )
857

858

859
def bulk_save_pids(entity, file_name, verbose=False):
1✔
860
    """Bulk save entity data from pids table."""
861
    if verbose:
×
862
        click.echo(f"{entity} save to file: {file_name}")
×
863
    table = "pidstore_pid"
×
864
    columns = (
×
865
        "created",
866
        "updated",
867
        "pid_type",
868
        "pid_value",
869
        "status",
870
        "object_type",
871
        "object_uuid",
872
    )
873
    tmp_file_name = f"{file_name}_tmp"
×
874
    bulk_save_entity(
×
875
        file_name=tmp_file_name, table=table, columns=columns, verbose=verbose
876
    )
877
    # clean pid file
878
    with open(tmp_file_name) as file_in, open(file_name, "w") as file_out:
×
879
        file_out.writelines(line for line in file_in if entity in line)
×
880
    os.remove(tmp_file_name)
×
881

882

883
def bulk_save_ids(entity, file_name, verbose=False):
1✔
884
    """Bulk save entity data from id table."""
885
    if verbose:
×
886
        click.echo(f"{entity} save to file: {file_name}")
×
887
    entity_class = get_entity_class(entity)
×
888
    metadata, identifier = entity_class.get_metadata_identifier_names()
×
889
    columns = ("recid",)
×
890
    bulk_save_entity(
×
891
        file_name=file_name, table=identifier, columns=columns, verbose=verbose
892
    )
893

894

895
def create_md5(record):
1✔
896
    """Create md5 for record."""
897
    return hashlib.md5(
1✔
898
        json.dumps(record, sort_keys=True, default=str).encode("utf-8")
899
    ).hexdigest()
900

901

902
def add_md5(record):
1✔
903
    """Add md5 to json."""
904
    schema = record.pop("$schema") if record.get("$schema") else None
1✔
905
    if record.get("md5"):
1✔
906
        record.pop("md5")
1✔
907
    record["md5"] = create_md5(record)
1✔
908
    if schema:
1✔
909
        record["$schema"] = schema
1✔
910
    return record
1✔
911

912

913
def add_schema(record, entity):
1✔
914
    """Add the $schema to the record."""
915
    with current_app.app_context():
1✔
916
        schemas = current_app.config.get("RECORDS_JSON_SCHEMA")
1✔
917
        if entity in schemas:
1✔
918
            base_url = current_app.config.get("RERO_MEF_APP_BASE_URL")
1✔
919
            endpoint = current_app.config.get("JSONSCHEMAS_ENDPOINT")
1✔
920
            schema = schemas[entity]
1✔
921
            record["$schema"] = f"{base_url}{endpoint}{schema}"
1✔
922
    return record
1✔
923

924

925
def create_csv_file(input_file, entity, pidstore, metadata):
1✔
926
    """Create entity CSV file to load."""
927
    count = 0
×
928
    with (
×
929
        open(input_file, encoding="utf-8") as entity_file,
930
        open(metadata, "w", encoding="utf-8") as entity_metadata_file,
931
        open(pidstore, "w", encoding="utf-8") as entity_pids_file,
932
    ):
933
        for record in ijson.items(entity_file, "item"):
×
934
            if entity == "viaf":
×
935
                record["pid"] = record["viaf_pid"]
×
936

937
            ordered_record = add_md5(record)
×
938
            add_schema(ordered_record, entity)
×
939

940
            record_uuid = str(uuid4())
×
941
            date = str(datetime.now(timezone.utc))
×
942

943
            entity_metadata_file.write(
×
944
                metadata_csv_line(ordered_record, record_uuid, date)
945
            )
946

947
            entity_pids_file.write(
×
948
                pidstore_csv_line(entity, record["pid"], record_uuid, date)
949
            )
950
            count += 1
×
951
    return count
×
952

953

954
def get_entity_classes(without_mef_viaf=True):
1✔
955
    """Get entity classes from config."""
956
    entities = {}
1✔
957
    endpoints = deepcopy(current_app.config.get("RECORDS_REST_ENDPOINTS", {}))
1✔
958
    if without_mef_viaf:
1✔
959
        endpoints.pop("mef", None)
1✔
960
        endpoints.pop("viaf", None)
1✔
961
        endpoints.pop("comef", None)
1✔
962
        endpoints.pop("plmef", None)
1✔
963
    for entity in endpoints:
1✔
964
        if record_class := obj_or_import_string(endpoints[entity].get("record_class")):
1✔
965
            entities[entity] = record_class
1✔
966
    return entities
1✔
967

968

969
def get_endpoint_class(entity, class_name):
1✔
970
    """Get entity class from config."""
971
    endpoints = current_app.config.get("RECORDS_REST_ENDPOINTS", {})
1✔
972
    if endpoint := endpoints.get(entity, {}):
1✔
973
        return obj_or_import_string(endpoint.get(class_name))
1✔
974
    return None
×
975

976

977
def get_entity_class(entity):
1✔
978
    """Get entity record class from config."""
979
    if entity := get_endpoint_class(entity=entity, class_name="record_class"):
1✔
980
        return entity
1✔
981
    return None
×
982

983

984
def get_entity_search_class(entity):
1✔
985
    """Get entity search class from config."""
986
    if search := get_endpoint_class(entity=entity, class_name="search_class"):
1✔
987
        return search
1✔
988
    return None
×
989

990

991
def get_entity_indexer_class(entity):
1✔
992
    """Get entity indexer class from config."""
993
    if search := get_endpoint_class(entity=entity, class_name="indexer_class"):
×
994
        return search
×
995
    return None
×
996

997

998
def write_viaf_json(
1✔
999
    pidstore_file, metadata_file, viaf_pid, corresponding_data, verbose=False
1000
):
1001
    """Write a JSON record into VIAF file."""
1002
    from rero_mef.agents import AgentViafRecord
1✔
1003

1004
    json_data = {}
1✔
1005
    for source, value in corresponding_data.items():
1✔
1006
        if source in AgentViafRecord.sources:
1✔
1007
            key = AgentViafRecord.sources[source]["name"]
1✔
1008
            if pid := value.get("pid"):
1✔
1009
                json_data[f"{key}_pid"] = pid
1✔
1010
                if url := value.get("url"):
1✔
1011
                    json_data[key] = url
1✔
1012
        elif source == "Wikipedia":
1✔
1013
            if pid := value.get("pid"):
1✔
1014
                json_data["wiki_pid"] = pid
×
1015
            if wiki_urls := value.get("url"):
1✔
1016
                json_data["wiki"] = sorted(wiki_urls)
1✔
1017

1018
    json_data["md5"] = create_md5(json_data)
1✔
1019
    add_schema(json_data, "viaf")
1✔
1020
    json_data["pid"] = viaf_pid
1✔
1021
    # only save VIAF data with used pids
1022
    record_uuid = str(uuid4())
1✔
1023
    date = str(datetime.now(timezone.utc))
1✔
1024
    pidstore_file.write(pidstore_csv_line("viaf", viaf_pid, record_uuid, date))
1✔
1025
    metadata_file.write(metadata_csv_line(json_data, record_uuid, date))
1✔
1026
    if verbose:
1✔
1027
        click.echo(f"  VIAF: {json_data}")
×
1028

1029

1030
def append_fixtures_new_identifiers(identifier, pids, pid_type):
1✔
1031
    """Insert pids into the indentifier table and update its sequence."""
1032
    with db.session.begin_nested():
×
1033
        for pid in pids:
×
1034
            db.session.add(identifier(recid=pid))
×
1035
        max_pid = (
×
1036
            PersistentIdentifier.query.filter_by(pid_type=pid_type)
1037
            .order_by(
1038
                sqlalchemy.desc(
1039
                    sqlalchemy.cast(PersistentIdentifier.pid_value, sqlalchemy.Integer)
1040
                )
1041
            )
1042
            .first()
1043
            .pid_value
1044
        )
1045
        identifier._set_sequence(max_pid)
×
1046

1047

1048
def set_timestamp(name, **kwargs):
1✔
1049
    """Set timestamp in current cache.
1050

1051
    Allows to timestamp functionality and monitoring of the changed
1052
    timestamps externaly via url requests.
1053

1054
    :param name: name of time stamp.
1055
    :returns: time of time stamp
1056
    """
1057
    time_stamps = current_cache.get("timestamps")
1✔
1058
    if not time_stamps:
1✔
1059
        time_stamps = {}
1✔
1060
    utc_now = datetime.now(timezone.utc)
1✔
1061
    time_stamps[name] = {}
1✔
1062
    time_stamps[name]["time"] = utc_now
1✔
1063
    for key, value in kwargs.items():
1✔
1064
        time_stamps[name][key] = value
1✔
1065
    current_cache.set("timestamps", time_stamps)
1✔
1066
    return utc_now
1✔
1067

1068

1069
def get_timestamp(name):
1✔
1070
    """Get timestamp in current cache.
1071

1072
    :param name: name of time stamp.
1073
    :returns: time of time stamp
1074
    """
1075
    time_stamps = current_cache.get("timestamps")
1✔
1076
    return time_stamps.get(name) if time_stamps else None
1✔
1077

1078

1079
class JsonWriter:
1✔
1080
    """Json Writer."""
1081

1082
    count = 0
1✔
1083

1084
    def __init__(self, filename, indent=2):
1✔
1085
        """Constructor.
1086

1087
        :params filename: File name of the file to be written.
1088
        :param indent: indentation.
1089
        """
1090
        self.indent = indent
1✔
1091
        self.file_handle = open(filename, "w")
1✔
1092
        self.file_handle.write("[")
1✔
1093

1094
    def __del__(self):
1✔
1095
        """Destructor."""
1096
        if self.file_handle:
1✔
1097
            self.file_handle.write("\n]")
1✔
1098
            self.file_handle.close()
1✔
1099
            self.file_handle = None
1✔
1100

1101
    def __enter__(self):
1✔
1102
        """Context manager enter."""
1103
        return self
1✔
1104

1105
    def __exit__(self, exception_type, exception_value, exception_traceback):
1✔
1106
        """Context manager exit.
1107

1108
        :params exception_type: indicates class of exception.
1109
        :params exception_value: indicates type of exception.
1110
            like divide_by_zero error, floating_point_error,
1111
            which are types of arithmetic exception.
1112
        :params exception_traceback: traceback is a report which has all
1113
            of the information needed to solve the exception.
1114
        """
1115
        self.__del__()
1✔
1116

1117
    def write(self, data):
1✔
1118
        """Write data to file.
1119

1120
        :param data: JSON data to write into the file.
1121
        """
1122
        if self.count > 0:
1✔
1123
            self.file_handle.write(",")
1✔
1124
        if self.indent:
1✔
1125
            for line in dumps(data, indent=self.indent).split("\n"):
1✔
1126
                self.file_handle.write(f"\n{' '.ljust(self.indent)}")
1✔
1127
                self.file_handle.write(line)
1✔
1128
        else:
1129
            self.file_handle.write(dumps(data), separators=(",", ":"))
×
1130
        self.count += 1
1✔
1131

1132
    def close(self):
1✔
1133
        """Close file."""
1134
        self.__del__()
×
1135

1136

1137
def mef_get_all_missing_entity_pids(mef_class, entity, verbose=False):
1✔
1138
    """Get all missing entity pids.
1139

1140
    :param mef_class: MEF class to use.
1141
    :param entity: entity name to get the missing pids.
1142
    :param verbose: Verbose.
1143
    :returns: Missing VIAF pids.
1144
    """
1145
    record_class = get_entity_class(entity)
×
1146
    non_existing_pids = {}
×
1147
    no_pids = []
×
1148
    if verbose:
×
1149
        click.echo(f"Get pids from {entity} ...")
×
1150
    progress = progressbar(
×
1151
        items=record_class.get_all_pids(), length=record_class.count(), verbose=verbose
1152
    )
1153
    missing_pids = dict.fromkeys(progress, 1)
×
1154
    name = record_class.name
×
1155
    if verbose:
×
1156
        click.echo(f"Get pids for {name} from MEF and calculate missing ...")
×
1157
    query = mef_class.search().filter("exists", field=name)
×
1158
    progress = progressbar(
×
1159
        items=query.source(["pid", name]).scan(), length=query.count(), verbose=True
1160
    )
1161
    for hit in progress:
×
1162
        data = hit.to_dict()
×
1163
        if entity_pid := data.get(name, {}).get("pid"):
×
1164
            res = missing_pids.pop(entity_pid, False)
×
1165
            if not res:
×
1166
                non_existing_pids[hit.pid] = entity_pid
×
1167
        else:
1168
            no_pids.append(hit.pid)
×
1169
    return list(missing_pids), non_existing_pids, no_pids
×
1170

1171

1172
def get_mefs_endpoints():
1✔
1173
    """Get all enpoints for MEF's."""
1174
    from rero_mef.agents import AgentMefRecord
1✔
1175
    from rero_mef.agents.utils import get_agent_endpoints
1✔
1176
    from rero_mef.concepts import ConceptMefRecord
1✔
1177
    from rero_mef.concepts.utils import get_concept_endpoints
1✔
1178
    from rero_mef.places import PlaceMefRecord
1✔
1179
    from rero_mef.places.utils import get_places_endpoints
1✔
1180

1181
    mefs = [{"mef_class": AgentMefRecord, "endpoints": get_agent_endpoints()}]
1✔
1182
    mefs.append({"mef_class": ConceptMefRecord, "endpoints": get_concept_endpoints()})
1✔
1183
    mefs.append({"mef_class": PlaceMefRecord, "endpoints": get_places_endpoints()})
1✔
1184
    return mefs
1✔
1185

1186

1187
def generate(search, deleted):
1✔
1188
    """Lagging genarator."""
1189
    yield "["
1✔
1190
    idx = 0
1✔
1191
    for hit in search.scan():
1✔
1192
        if idx != 0:
1✔
1193
            yield ", "
1✔
1194
        yield json.dumps(hit.to_dict())
1✔
1195
        idx += 1
1✔
1196
    for idx_deleted, record in enumerate(deleted):
1✔
1197
        if idx + idx_deleted != 0:
1✔
1198
            yield ", "
1✔
1199
        yield json.dumps(record)
1✔
1200
    yield "]"
1✔
1201

1202

1203
def requests_retry_session(
1✔
1204
    retries=5, backoff_factor=0.5, status_forcelist=(500, 502, 504), session=None
1205
):
1206
    """Request retry session.
1207

1208
    :params retries: The total number of retry attempts to make.
1209
    :params backoff_factor: Sleep between failed requests.
1210
        {backoff factor} * (2 ** ({number of total retries} - 1))
1211
    :params status_forcelist: The HTTP response codes to retry on..
1212
    :params session: Session to use.
1213
    :returns: http request session.
1214

1215
    """
1216
    session = session or requests.Session()
1✔
1217
    retry = Retry(
1✔
1218
        total=retries,
1219
        read=retries,
1220
        connect=retries,
1221
        backoff_factor=backoff_factor,
1222
        status_forcelist=status_forcelist,
1223
    )
1224
    adapter = HTTPAdapter(max_retries=retry)
1✔
1225
    session.mount("http://", adapter)
1✔
1226
    session.mount("https://", adapter)
1✔
1227
    return session
1✔
1228

1229

1230
def make_identifier(identified_by):
1✔
1231
    """Make identifier `type|(source)value`.
1232

1233
    :param identified_by: identifiedBy to create identifier
1234
    :return: identifier `type|(source)value` or `type|value`
1235
    """
1236
    if source := identified_by.get("source"):
1✔
1237
        return f"{identified_by['type']}|({source}){identified_by['value']}"
1✔
1238
    return f"{identified_by['type']}|{identified_by['value']}"
1✔
1239

1240

1241
def build_ref_string(entity_type, entity_name, entity_pid):
1✔
1242
    """Build url for  api.
1243

1244
    :param entity_type: Type of entity (agents, concepts, places)
1245
    :param entity_name: Name of entity.
1246
    :param entity_pid: Pid of entity.
1247
    :returns: Reference string to record.
1248
    """
1249
    with current_app.app_context():
1✔
1250
        return (
1✔
1251
            f"{current_app.config.get('RERO_MEF_APP_BASE_URL')}"
1252
            f"/api/{entity_type}/{entity_name}/{entity_pid}"
1253
        )
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