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

iplweb / bpp / 0950478e-207f-4389-967f-fb3a6c1090d4

01 Apr 2025 12:57PM UTC coverage: 43.279% (-3.3%) from 46.628%
0950478e-207f-4389-967f-fb3a6c1090d4

push

circleci

mpasternak
Merge branch 'release/v202504.1175'

1 of 19 new or added lines in 5 files covered. (5.26%)

1780 existing lines in 123 files now uncovered.

15876 of 36683 relevant lines covered (43.28%)

0.79 hits per line

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

24.7
src/pbn_api/client.py
1
import random
2✔
2
import sys
2✔
3
import time
2✔
4
import warnings
2✔
5
from builtins import NotImplementedError
2✔
6
from pprint import pprint
2✔
7
from urllib.parse import parse_qs, quote, urlparse
2✔
8

9
import requests
2✔
10
from django.core.mail import mail_admins
2✔
11
from django.db import transaction
2✔
12
from django.db.models import Model
2✔
13
from requests import ConnectionError
2✔
14
from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError
2✔
15
from requests.exceptions import SSLError
2✔
16
from sentry_sdk import capture_exception
2✔
17
from simplejson.errors import JSONDecodeError
2✔
18

19
from import_common.core import (
2✔
20
    matchuj_aktualna_dyscypline_pbn,
21
    matchuj_nieaktualna_dyscypline_pbn,
22
)
23
from import_common.normalization import normalize_kod_dyscypliny
2✔
24
from pbn_api.adapters.wydawnictwo import (
2✔
25
    OplataZaWydawnictwoPBNAdapter,
26
    WydawnictwoPBNAdapter,
27
)
28
from pbn_api.const import (
2✔
29
    DEFAULT_BASE_URL,
30
    NEEDS_PBN_AUTH_MSG,
31
    PBN_DELETE_PUBLICATION_STATEMENT,
32
    PBN_GET_DISCIPLINES_URL,
33
    PBN_GET_INSTITUTION_STATEMENTS,
34
    PBN_GET_JOURNAL_BY_ID,
35
    PBN_GET_LANGUAGES_URL,
36
    PBN_GET_PUBLICATION_BY_ID_URL,
37
    PBN_POST_PUBLICATION_FEE_URL,
38
    PBN_POST_PUBLICATIONS_URL,
39
    PBN_SEARCH_PUBLICATIONS_URL,
40
)
41
from pbn_api.exceptions import (
2✔
42
    AccessDeniedException,
43
    AuthenticationConfigurationError,
44
    AuthenticationResponseError,
45
    HttpException,
46
    NeedsPBNAuthorisationException,
47
    NoFeeDataException,
48
    NoPBNUIDException,
49
    PBNUIDChangedException,
50
    PBNUIDSetToExistentException,
51
    PraceSerwisoweException,
52
    ResourceLockedException,
53
    SameDataUploadedRecently,
54
)
55
from pbn_api.models import TlumaczDyscyplin
2✔
56
from pbn_api.models.discipline import Discipline, DisciplineGroup
2✔
57
from pbn_api.models.sentdata import SentData
2✔
58

59
from django.contrib.contenttypes.models import ContentType
2✔
60

61
from django.utils.itercompat import is_iterable
2✔
62

63

64
def smart_content(content):
2✔
65
    try:
×
66
        return content.decode("utf-8")
×
67
    except UnicodeDecodeError:
×
68
        return content
×
69

70

71
class PBNClientTransport:
2✔
72
    def __init__(self, app_id, app_token, base_url, user_token=None):
2✔
73
        self.app_id = app_id
×
74
        self.app_token = app_token
×
75

76
        self.base_url = base_url
×
77
        if self.base_url is None:
×
78
            self.base_url = DEFAULT_BASE_URL
×
79

80
        self.access_token = user_token
×
81

82

83
class PageableResource:
2✔
84
    def __init__(self, transport, res, url, headers, body=None, method="get"):
2✔
85
        self.url = url
×
86
        self.headers = headers
×
87
        self.transport = transport
×
88
        self.body = body
×
89
        self.method = getattr(transport, method)
×
90

91
        try:
×
92
            self.page_0 = res["content"]
×
93
        except KeyError:
×
94
            self.page_0 = []
×
95

96
        self.current_page = res["number"]
×
97
        self.total_elements = res["totalElements"]
×
98
        self.total_pages = res["totalPages"]
×
99
        self.done = False
×
100

101
    def count(self):
2✔
102
        return self.total_elements
×
103

104
    def fetch_page(self, current_page):
2✔
105
        if current_page == 0:
×
106
            return self.page_0
×
107
        # print(f"FETCH {current_page}")
108

109
        kw = {"headers": self.headers}
×
110
        if self.body:
×
111
            kw["body"] = self.body
×
112

113
        ret = self.method(self.url + f"&page={current_page}", **kw)
×
114
        # print(f"FETCH DONE {current_page}")
115

116
        try:
×
117
            return ret["content"]
×
118
        except KeyError:
×
119
            return
×
120

121
    def __iter__(self):
2✔
122
        for n in range(0, self.total_pages):
×
123
            yield from self.fetch_page(n)
×
124

125

126
class OAuthMixin:
2✔
127
    @classmethod
2✔
128
    def get_auth_url(klass, base_url, app_id):
2✔
129
        return f"{base_url}/auth/pbn/api/registration/user/token/{app_id}"
×
130

131
    @classmethod
2✔
132
    def get_user_token(klass, base_url, app_id, app_token, one_time_token):
2✔
133
        headers = {
×
134
            "X-App-Id": app_id,
135
            "X-App-Token": app_token,
136
        }
137
        body = {"oneTimeToken": one_time_token}
×
138
        url = f"{base_url}/auth/pbn/api/user/token"
×
139
        response = requests.post(url=url, json=body, headers=headers)
×
140
        try:
×
141
            response.json()
×
142
        except ValueError:
×
143
            if response.content.startswith(b"Mismatched X-APP-TOKEN: "):
×
144
                raise AuthenticationConfigurationError(
×
145
                    "Token aplikacji PBN nieprawidłowy. Poproś administratora "
146
                    "o skonfigurowanie prawidłowego tokena aplikacji PBN w "
147
                    "ustawieniach obiektu Uczelnia. "
148
                )
149

150
            raise AuthenticationResponseError(response.content)
×
151

152
        return response.json().get("X-User-Token")
×
153

154
    def authorize(self, base_url, app_id, app_token):
2✔
155
        from pbn_api.conf import settings
×
156

157
        if self.access_token:
×
158
            return True
×
159

160
        self.access_token = getattr(settings, "PBN_CLIENT_USER_TOKEN")
×
161
        if self.access_token:
×
162
            return True
×
163

164
        auth_url = OAuthMixin.get_auth_url(base_url, app_id)
×
165

166
        print(
×
167
            f"""I have launched a web browser with {auth_url} ,\nplease log-in,
168
             then paste the redirected URL below. \n"""
169
        )
170
        import webbrowser
×
171

172
        webbrowser.open(auth_url)
×
173
        redirect_response = input("Paste the full redirect URL here:")
×
174
        one_time_token = parse_qs(urlparse(redirect_response).query).get("ott")[0]
×
175
        print("ONE TIME TOKEN", one_time_token)
×
176

177
        self.access_token = OAuthMixin.get_user_token(
×
178
            base_url, app_id, app_token, one_time_token
179
        )
180

181
        print("ACCESS TOKEN", self.access_token)
×
182
        return True
×
183

184

185
class RequestsTransport(OAuthMixin, PBNClientTransport):
2✔
186
    def get(self, url, headers=None, fail_on_auth_missing=False):
2✔
187
        sent_headers = {"X-App-Id": self.app_id, "X-App-Token": self.app_token}
×
188
        if self.access_token:
×
189
            sent_headers["X-User-Token"] = self.access_token
×
190

191
        # Jeżeli ustawimy taki nagłówek dla "niewinnych" zapytań GET, to PBN
192
        # API odrzuca takie połączenie z kodem 403, stąd nie:
193
        # if hasattr(self, "access_token"):
194
        #     sent_headers["X-User-Token"] = self.access_token
195

196
        if headers is not None:
×
197
            sent_headers.update(headers)
×
198

199
        retries = 0
×
200
        MAX_RETRIES = 15
×
201

202
        while retries < MAX_RETRIES:
×
203
            try:
×
204
                ret = requests.get(self.base_url + url, headers=sent_headers)
×
205
                break
×
206
            except (SSLError, ConnectionError) as e:
×
207
                retries += 1
×
208
                time.sleep(random.randint(1, 5))
×
209
                if retries >= MAX_RETRIES:
×
210
                    raise e
×
211

212
        if ret.status_code == 403:
×
213
            if fail_on_auth_missing:
×
214
                raise AccessDeniedException(url, smart_content(ret.content))
×
215
            # Needs auth
216
            if ret.json()["message"] == "Access Denied":
×
217
                # Autoryzacja użytkownika jest poprawna, jednakże nie ma on po stronie PBN
218
                # takiego uprawnienia...
219
                raise AccessDeniedException(url, smart_content(ret.content))
×
220

221
            # elif ret.json['message'] == "Forbidden":  # <== to dostaniemy, gdy token zły lub brak
222

223
            if hasattr(self, "authorize"):
×
224
                ret = self.authorize(self.base_url, self.app_id, self.app_token)
×
225
                if not ret:
×
226
                    return
×
227

228
                # Podejmuj ponowną próbę tylko w przypadku udanej autoryzacji
229
                return self.get(url, headers, fail_on_auth_missing=True)
×
230

231
        if ret.status_code >= 400:
×
232
            raise HttpException(ret.status_code, url, smart_content(ret.content))
×
233

234
        try:
×
235
            return ret.json()
×
236
        except (RequestsJSONDecodeError, JSONDecodeError) as e:
×
237
            if ret.status_code == 200 and b"prace serwisowe" in ret.content:
×
238
                # open("pbn_client_dump.html", "wb").write(ret.content)
239
                raise PraceSerwisoweException()
×
240
            raise e
×
241

242
    def post(self, url, headers=None, body=None, delete=False):
2✔
243
        if not hasattr(self, "access_token"):
×
244
            ret = self.authorize(self.base_url, self.app_id, self.app_token)
×
245
            if not ret:
×
246
                return
×
247
            return self.post(url, headers=headers, body=body, delete=delete)
×
248

249
        sent_headers = {
×
250
            "X-App-Id": self.app_id,
251
            "X-App-Token": self.app_token,
252
            "X-User-Token": self.access_token,
253
        }
254

255
        if headers is not None:
×
256
            sent_headers.update(headers)
×
257

258
        method = requests.post
×
259
        if delete:
×
260
            method = requests.delete
×
261

262
        ret = method(self.base_url + url, headers=sent_headers, json=body)
×
263
        if ret.status_code == 403:
×
264
            try:
×
265
                ret_json = ret.json()
×
266
            except BaseException:
×
267
                raise HttpException(
×
268
                    ret.status_code,
269
                    url,
270
                    "Blad podczas odkodowywania JSON podczas odpowiedzi 403: "
271
                    + smart_content(ret.content),
272
                )
273

274
            # Needs auth
275
            if ret_json.get("message") == "Access Denied":
×
276
                # Autoryzacja użytkownika jest poprawna, jednakże nie ma on po stronie PBN
277
                # takiego uprawnienia...
278
                raise AccessDeniedException(url, smart_content(ret.content))
×
279

280
            if ret_json.get("message") == "Forbidden" and ret_json.get(
×
281
                "description"
282
            ).startswith(NEEDS_PBN_AUTH_MSG):
283
                # (403, '/api/v1/search/publications?size=10', '{"code":403,"message":"Forbidden",
284
                # "description":"W celu poprawnej autentykacji należy podać poprawny token użytkownika aplikacji. Podany
285
                # token użytkownika ... w ramach aplikacji ... nie istnieje lub został
286
                # unieważniony!"}')
287
                raise NeedsPBNAuthorisationException(
×
288
                    ret.status_code, url, smart_content(ret.content)
289
                )
290

291
            # mpasternak, 5.09.2021: nie do końca jestem pewny, czy kod wywołujący self.authorize (nast. 2 linijki)
292
            # zostawić w tej sytuacji. Tak było 'historycznie', ale widzę też, że po wywołaniu self.authorize
293
            # ta funkcja zawsze wykonywała "if ret.status_code >= 403" i zwracała Exception. Teoretycznie
294
            # to chyba zostało tutaj z powodu klienta command-line, który to na ten moment priorytetem
295
            # przecież nie jest.
296
            if hasattr(self, "authorize"):
×
297
                self.authorize(self.base_url, self.app_id, self.app_token)
×
298
                # self.authorize()
299

300
        # mpasternak 7.09.2021, poniżej "przymiarki" do analizowania zwróconych błędów z PBN
301
        #
302
        # if ret.status_code == 400:
303
        #     try:
304
        #         ret_json = ret.json()
305
        #     except BaseException:
306
        #         raise HttpException(
307
        #             ret.status_code,
308
        #             url,
309
        #             "Blad podczas odkodowywania JSON podczas odpowiedzi 400: "
310
        #             + smart_content(ret.content),
311
        #         )
312
        #     if ret_json.get("message") == "Bad Request" and ret_json.get("description") == "Validation failed."
313
        #     and ret_json.get("details")
314
        #
315
        #     HttpException(400, '/api/v1/publications',
316
        #                   '{"code":400,"message":"Bad Request","description":"Validation failed.",
317
        #                   "details":{"doi":"DOI jest błędny lub nie udało się pobrać informacji z serwisu DOI!"}}')
318

319
        if ret.status_code >= 400:
×
320
            if ret.status_code == 423 and smart_content(ret.content) == "Locked":
×
321
                raise ResourceLockedException(
×
322
                    ret.status_code, url, smart_content(ret.content)
323
                )
324

325
            raise HttpException(ret.status_code, url, smart_content(ret.content))
×
326

327
        try:
×
328
            return ret.json()
×
329
        except (RequestsJSONDecodeError, JSONDecodeError) as e:
×
330
            if ret.status_code == 200:
×
331
                if ret.content == b"":
×
332
                    return
×
333

334
                if b"prace serwisowe" in ret.content:
×
335
                    # open("pbn_client_dump.html", "wb").write(ret.content)
336
                    raise PraceSerwisoweException()
×
337

338
            raise e
×
339

340
    def delete(
2✔
341
        self,
342
        url,
343
        headers=None,
344
        body=None,
345
    ):
346
        return self.post(url, headers, body, delete=True)
×
347

348
    def _pages(self, method, url, headers=None, body=None, page_size=10, *args, **kw):
2✔
349
        # Stronicowanie zwraca rezultaty w taki sposób:
350
        # {'content': [{'mongoId': '5e709189878c28a04737dc6f',
351
        #               'status': 'ACTIVE',
352
        # ...
353
        #              'versionHash': '---'}]}],
354
        #  'first': True,
355
        #  'last': False,
356
        #  'number': 0,
357
        #  'numberOfElements': 10,
358
        #  'pageable': {'offset': 0,
359
        #               'pageNumber': 0,
360
        #               'pageSize': 10,
361
        #               'paged': True,
362
        #               'sort': {'sorted': False, 'unsorted': True},
363
        #               'unpaged': False},
364
        #  'size': 10,
365
        #  'sort': {'sorted': False, 'unsorted': True},
366
        #  'totalElements': 68577,
367
        #  'totalPages': 6858}
368

369
        chr = "?"
×
370
        if url.find("?") >= 0:
×
371
            chr = "&"
×
372

373
        url = url + f"{chr}size={page_size}"
×
374
        chr = "&"
×
375

376
        for elem in kw:
×
377
            url += chr + elem + "=" + quote(kw[elem])
×
378

379
        method_function = getattr(self, method)
×
380

381
        if method == "get":
×
382
            res = method_function(url, headers)
×
383
        elif method == "post":
×
384
            res = method_function(url, headers, body=body)
×
385
        else:
386
            raise NotImplementedError
×
387

388
        if "pageable" not in res:
×
389
            warnings.warn(
×
390
                f"PBNClient.{method}_page request for {url} with headers {headers} did not return a paged resource, "
391
                f"maybe use PBNClient.{method} (without 'page') instead",
392
                RuntimeWarning,
393
            )
394
            return res
×
395
        return PageableResource(
×
396
            self, res, url=url, headers=headers, body=body, method=method
397
        )
398

399
    def get_pages(self, url, headers=None, page_size=10, *args, **kw):
2✔
400
        return self._pages(
×
401
            "get", url=url, headers=headers, page_size=page_size, *args, **kw
402
        )
403

404
    def post_pages(self, url, headers=None, body=None, page_size=10, *args, **kw):
2✔
405
        # Jak get_pages, ale methoda to post
406
        if body is None:
×
407
            body = kw
×
408

409
        return self._pages(
×
410
            "post",
411
            url=url,
412
            headers=headers,
413
            body=body,
414
            page_size=page_size,
415
            *args,
416
            **kw,
417
        )
418

419

420
class ConferencesMixin:
2✔
421
    def get_conferences(self, *args, **kw):
2✔
422
        return self.transport.get_pages("/api/v1/conferences/page", *args, **kw)
×
423

424
    def get_conferences_mnisw(self, *args, **kw):
2✔
425
        return self.transport.get_pages("/api/v1/conferences/mnisw/page", *args, **kw)
×
426

427
    def get_conference(self, id):
2✔
428
        return self.transport.get(f"/api/v1/conferences/{id}")
×
429

430
    def get_conference_editions(self, id):
2✔
431
        return self.transport.get(f"/api/v1/conferences/{id}/editions")
×
432

433
    def get_conference_metadata(self, id):
2✔
434
        return self.transport.get(f"/api/v1/conferences/{id}/metadata")
×
435

436

437
class DictionariesMixin:
2✔
438
    def get_countries(self):
2✔
439
        return self.transport.get("/api/v1/dictionary/countries")
×
440
        return self.transport.get("/api/v1/dictionary/countries")
441

442
    def get_disciplines(self):
2✔
443
        return self.transport.get(PBN_GET_DISCIPLINES_URL)
×
444

445
    def get_languages(self):
2✔
446
        return self.transport.get(PBN_GET_LANGUAGES_URL)
×
447

448

449
class InstitutionsMixin:
2✔
450
    def get_institutions(self, status="ACTIVE", *args, **kw):
2✔
451
        return self.transport.get_pages(
×
452
            "/api/v1/institutions/page", status=status, *args, **kw
453
        )
454

455
    def get_institution_by_id(self, id):
2✔
456
        return self.transport.get(f"/api/v1/institutions/{id}")
×
457

458
    def get_institution_by_version(self, version):
2✔
459
        return self.transport.get_pages(f"/api/v1/institutions/version/{version}")
×
460

461
    def get_institution_metadata(self, id):
2✔
462
        return self.transport.get_pages(f"/api/v1/institutions/{id}/metadata")
×
463

464
    def get_institutions_polon(self, includeAllVersions="true", *args, **kw):
2✔
465
        return self.transport.get_pages(
×
466
            "/api/v1/institutions/polon/page",
467
            includeAllVersions=includeAllVersions,
468
            *args,
469
            **kw,
470
        )
471

472
    def get_institutions_polon_by_uid(self, uid):
2✔
473
        return self.transport.get(f"/api/v1/institutions/polon/uid/{uid}")
×
474

475
    def get_institutions_polon_by_id(self, id):
2✔
476
        return self.transport.get(f"/api/v1/institutions/polon/{id}")
×
477

478

479
class InstitutionsProfileMixin:
2✔
480
    # XXX: wymaga autoryzacji
481
    def get_institution_publications(self, page_size=10) -> PageableResource:
2✔
482
        return self.transport.get_pages(
×
483
            "/api/v1/institutionProfile/publications/page", page_size=page_size
484
        )
485

486
    def get_institution_statements(self, page_size=10):
2✔
487
        return self.transport.get_pages(
×
488
            PBN_GET_INSTITUTION_STATEMENTS,
489
            page_size=page_size,
490
        )
491

492
    def get_institution_statements_of_single_publication(
2✔
493
        self, pbn_uid_id, page_size=50
494
    ):
495
        return self.transport.get_pages(
×
496
            PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=" + pbn_uid_id,
497
            page_size=page_size,
498
        )
499

500
    def delete_all_publication_statements(self, publicationId):
2✔
501
        return self.transport.delete(
×
502
            PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=publicationId),
503
            body={"all": True, "statementsOfPersons": []},
504
        )
505

506
    def delete_publication_statement(self, publicationId, personId, role):
2✔
507
        return self.transport.delete(
×
508
            PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=publicationId),
509
            body={"statementsOfPersons": [{"personId": personId, "role": role}]},
510
        )
511

512

513
class JournalsMixin:
2✔
514
    def get_journals_mnisw(self, *args, **kw):
2✔
515
        return self.transport.get_pages("/api/v1/journals/mnisw/page", *args, **kw)
×
516

517
    def get_journals_mnisw_v2(self, *args, **kw):
2✔
518
        return self.transport.get_pages("/api/v2/journals/mnisw/page", *args, **kw)
×
519

520
    def get_journals(self, *args, **kw):
2✔
521
        return self.transport.get_pages("/api/v1/journals/page", *args, **kw)
×
522

523
    def get_journals_v2(self, *args, **kw):
2✔
524
        return self.transport.get_pages("/api/v2/journals/page", *args, **kw)
×
525

526
    def get_journal_by_version(self, version):
2✔
527
        return self.transport.get(f"/api/v1/journals/version/{version}")
×
528

529
    def get_journal_by_id(self, id):
2✔
530
        return self.transport.get(PBN_GET_JOURNAL_BY_ID.format(id=id))
×
531

532
    def get_journal_metadata(self, id):
2✔
533
        return self.transport.get(f"/api/v1/journals/{id}/metadata")
×
534

535

536
class PersonMixin:
2✔
537
    def get_people_by_institution_id(self, id):
2✔
538
        return self.transport.get(f"/api/v1/person/institution/{id}")
×
539

540
    def get_person_by_natural_id(self, id):
2✔
541
        return self.transport.get(f"/api/v1/person/natural/{id}")
×
542

543
    def get_person_by_orcid(self, orcid):
2✔
544
        return self.transport.get(f"/api/v1/person/orcid/{orcid}")
×
545

546
    def get_people(self, *args, **kw):
2✔
547
        return self.transport.get_pages("/api/v1/person/page", *args, **kw)
×
548

549
    def get_person_by_polon_uid(self, uid):
2✔
550
        return self.transport.get(f"/api/v1/person/polon/{uid}")
×
551

552
    def get_person_by_version(self, version):
2✔
553
        return self.transport.get(f"/api/v1/person/version/{version}")
×
554

555
    def get_person_by_id(self, id):
2✔
556
        return self.transport.get(f"/api/v1/person/{id}")
×
557

558

559
class PublishersMixin:
2✔
560
    def get_publishers_mnisw(self, *args, **kw):
2✔
561
        return self.transport.get_pages("/api/v1/publishers/mnisw/page", *args, **kw)
×
562

563
    def get_publishers_mnisw_yearlist(self, *args, **kw):
2✔
564
        return self.transport.get_pages(
×
565
            "/api/v1/publishers/mnisw/page/yearlist", *args, **kw
566
        )
567

568
    def get_publishers(self, *args, **kw):
2✔
569
        return self.transport.get_pages("/api/v1/publishers/page", *args, **kw)
×
570

571
    def get_publisher_by_version(self, version):
2✔
572
        return self.transport.get(f"/api/v1/publishers/version/{version}")
×
573

574
    def get_publisher_by_id(self, id):
2✔
575
        return self.transport.get(f"/api/v1/publishers/{id}")
×
576

577
    def get_publisher_metadata(self, id):
2✔
578
        return self.transport.get(f"/api/v1/publishers/{id}/metadata")
×
579

580

581
class PublicationsMixin:
2✔
582
    def get_publication_by_doi(self, doi):
2✔
583
        return self.transport.get(
×
584
            f"/api/v1/publications/doi/?doi={quote(doi, safe='')}",
585
        )
586

587
    def get_publication_by_doi_page(self, doi):
2✔
588
        return self.transport.get_pages(
×
589
            f"/api/v1/publications/doi/page?doi={quote(doi, safe='')}",
590
            headers={"doi": doi},
591
        )
592

593
    def get_publication_by_id(self, id):
2✔
594
        return self.transport.get(PBN_GET_PUBLICATION_BY_ID_URL.format(id=id))
×
595

596
    def get_publication_metadata(self, id):
2✔
597
        return self.transport.get(f"/api/v1/publications/id/{id}/metadata")
×
598

599
    def get_publications(self, **kw):
2✔
600
        return self.transport.get_pages("/api/v1/publications/page", **kw)
×
601

602
    def get_publication_by_version(self, version):
2✔
603
        return self.transport.get(f"/api/v1/publications/version/{version}")
×
604

605

606
class SearchMixin:
2✔
607
    def search_publications(self, *args, **kw):
2✔
608
        return self.transport.post_pages(PBN_SEARCH_PUBLICATIONS_URL, body=kw)
×
609

610

611
class PBNClient(
2✔
612
    ConferencesMixin,
613
    DictionariesMixin,
614
    InstitutionsMixin,
615
    InstitutionsProfileMixin,
616
    JournalsMixin,
617
    PersonMixin,
618
    PublicationsMixin,
619
    PublishersMixin,
620
    SearchMixin,
621
):
622
    _interactive = False
2✔
623

624
    def __init__(self, transport: RequestsTransport):
2✔
UNCOV
625
        self.transport = transport
1✔
626

627
    def post_publication(self, json):
2✔
628
        return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json)
×
629

630
    def post_publication_fee(self, publicationId, json):
2✔
631
        return self.transport.post(
×
632
            PBN_POST_PUBLICATION_FEE_URL.format(id=publicationId), body=json
633
        )
634

635
    def get_publication_fee(self, publicationId):
2✔
636
        res = self.transport.post_pages(
×
637
            "/api/v1/institutionProfile/publications/search/fees",
638
            body={"publicationIds": [str(publicationId)]},
639
        )
640
        if not res.count():
×
641
            return
×
642
        elif res.count() == 1:
×
643
            return list(res)[0]
×
644
        else:
645
            raise NotImplementedError("count > 1")
×
646

647
    def upload_publication(
2✔
648
        self, rec, force_upload=False, export_pk_zero=None, always_affiliate_to_uid=None
649
    ):
650
        js = WydawnictwoPBNAdapter(
×
651
            rec,
652
            export_pk_zero=export_pk_zero,
653
            always_affiliate_to_uid=always_affiliate_to_uid,
654
        ).pbn_get_json()
655
        if not force_upload:
×
656
            needed = SentData.objects.check_if_needed(rec, js)
×
657
            if not needed:
×
658
                raise SameDataUploadedRecently(
×
659
                    SentData.objects.get_for_rec(rec).last_updated_on
660
                )
661
        try:
×
662
            ret = self.post_publication(js)
×
663
        except Exception as e:
×
664
            SentData.objects.updated(rec, js, uploaded_okay=False, exception=str(e))
×
665
            raise e
×
666

667
        return ret, js
×
668

669
    def download_publication(self, doi=None, objectId=None):
2✔
670
        from .integrator import zapisz_mongodb
×
671
        from .models import Publication
×
672

673
        assert doi or objectId
×
674

675
        if doi:
×
676
            data = self.get_publication_by_doi(doi)
×
677
        elif objectId:
×
678
            data = self.get_publication_by_id(objectId)
×
679

680
        return zapisz_mongodb(data, Publication)
×
681

682
    @transaction.atomic
2✔
683
    def download_statements_of_publication(self, pub):
2✔
684
        from pbn_api.models import OswiadczenieInstytucji
×
685
        from .integrator import pobierz_mongodb, zapisz_oswiadczenie_instytucji
×
686

687
        OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk).delete()
×
688

689
        pobierz_mongodb(
×
690
            self.get_institution_statements_of_single_publication(pub.pk, 5120),
691
            None,
692
            fun=zapisz_oswiadczenie_instytucji,
693
            client=self,
694
            disable_progress_bar=True,
695
        )
696

697
    def sync_publication(
2✔
698
        self,
699
        pub,
700
        notificator=None,
701
        force_upload=False,
702
        delete_statements_before_upload=False,
703
        export_pk_zero=None,
704
        always_affiliate_to_uid=None,
705
    ):
706
        """
707
        @param delete_statements_before_upload: gdy True, kasuj oświadczenia publikacji przed wysłaniem (jeżeli posiada
708
        PBN UID)
709
        """
710

711
        # if not pub.doi:
712
        #     raise WillNotExportError("Ustaw DOI dla publikacji")
713

714
        pub = self.eventually_coerce_to_publication(pub)
×
715

716
        #
717
        if (
×
718
            delete_statements_before_upload
719
            and hasattr(pub, "pbn_uid_id")
720
            and pub.pbn_uid_id is not None
721
        ):
722
            try:
×
723
                self.delete_all_publication_statements(pub.pbn_uid_id)
×
724

725
                # Jeżeli zostały skasowane dane, to wymuś wysłanie rekordu, niezależnie
726
                # od stanu tabeli SentData
727
                force_upload = True
×
728
            except HttpException as e:
×
729
                NIE_ISTNIEJA = "Nie istnieją oświadczenia dla publikacji"
×
730

731
                ignored_exception = False
×
732

733
                if e.status_code == 400:
×
734
                    if e.json:
×
735
                        try:
×
736
                            try:
×
737
                                msg = e.json["details"]["publicationId"]
×
738
                            except KeyError:
×
739
                                msg = e.json["details"][
×
740
                                    f"publicationId.{pub.pbn_uid_id}"
741
                                ]
742
                            if NIE_ISTNIEJA in msg:
×
743
                                ignored_exception = True
×
744
                        except (TypeError, KeyError):
×
745
                            if NIE_ISTNIEJA in e.content:
×
746
                                ignored_exception = True
×
747

748
                    else:
749
                        if NIE_ISTNIEJA in e.content:
×
750
                            ignored_exception = True
×
751

752
                if not ignored_exception:
×
753
                    raise e
×
754

755
        # Wgraj dane do PBN
756
        ret, js = self.upload_publication(
×
757
            pub,
758
            force_upload=force_upload,
759
            export_pk_zero=export_pk_zero,
760
            always_affiliate_to_uid=always_affiliate_to_uid,
761
        )
762

763
        if not ret.get("objectId", ""):
×
764
            msg = (
×
765
                f"UWAGA. Serwer PBN nie odpowiedział prawidłowym PBN UID dla"
766
                f" wysyłanego rekordu. Zgłoś sytuację do administratora serwisu. "
767
                f"{ret=}, {js=}, {pub=}"
768
            )
769
            if notificator is not None:
×
770
                notificator.error(msg)
×
771

772
            try:
×
773
                raise NoPBNUIDException(msg)
×
774
            except NoPBNUIDException as e:
×
775
                capture_exception(e)
×
776

777
            mail_admins("Serwer PBN nie zwrocil ID publikacji", msg, fail_silently=True)
×
778

779
        # Pobierz zwrotnie dane z PBN
780
        publication = self.download_publication(objectId=ret["objectId"])
×
781
        self.download_statements_of_publication(publication)
×
782

783
        # Utwórz obiekt zapisanych danych. Dopiero w tym miejscu, bo jeżeli zostanie
784
        # utworzony nowy rekord po stronie PBN, to pbn_uid_id musi wskazywać na
785
        # bazę w tabeli Publication, która została chwile temu pobrana...
786
        SentData.objects.updated(pub, js, pbn_uid_id=ret["objectId"])
×
787
        if pub.pbn_uid_id is not None and pub.pbn_uid_id != ret["objectId"]:
×
788
            SentData.objects.updated(pub, js, pbn_uid_id=pub.pbn_uid_id)
×
789

790
        if pub.pbn_uid_id != ret["objectId"]:
×
791
            # Rekord dostaje nowe objectId z PBNu.
792

793
            # Czy rekord JUŻ Z PBN UID dostaje nowe PBN UID z PBNu? Powiadom użytkownika.
794
            if pub.pbn_uid_id is not None:
×
795
                if notificator is not None:
×
796
                    notificator.error(
×
797
                        f"UWAGA UWAGA UWAGA. Wg danych z PBN zmodyfikowano PBN UID tego rekordu "
798
                        f"z wartości {pub.pbn_uid_id} na {ret['objectId']}. Technicznie nie jest to błąd, "
799
                        f"ale w praktyce dobrze by było zweryfikować co się zadziało, zarówno po stronie"
800
                        f"PBNu jak i BPP. Być może operujesz na rekordzie ze zdublowanym DOI/stronie WWW."
801
                    )
802

803
                message = (
×
804
                    f"Zarejestrowano zmianę ZAPISANEGO WCZEŚNIEJ PBN UID publikacji przez PBN, \n"
805
                    f"Publikacja:\n{pub}\n\n"
806
                    f"z UIDu {pub.pbn_uid_id} na {ret['objectId']}"
807
                )
808

809
                try:
×
810
                    raise PBNUIDChangedException(message)
×
811
                except PBNUIDChangedException as e:
×
812
                    capture_exception(e)
×
813

814
                mail_admins(
×
815
                    "Zmiana PBN UID publikacji przez serwer PBN",
816
                    message,
817
                    fail_silently=True,
818
                )
819

820
            # Z kolei poniższy kod odpowiada na sytuację dość niepożądana. Zakładamy, że do PBN został wysłany nowy,
821
            # czysty rekord czyli bez PBN UID, zas PBN odpowiedział PBN UIDem istniejącego już w bazie rekordu.
822
            from bpp.models import Rekord
×
823

824
            istniejace_rekordy = Rekord.objects.filter(pbn_uid_id=ret["objectId"])
×
825
            if pub.pbn_uid_id is None and istniejace_rekordy.exists():
×
826
                if notificator is not None:
×
827
                    notificator.error(
×
828
                        f'UWAGA UWAGA UWAGA. Wysłany rekord "{pub}" dostał w odpowiedzi z serwera PBN numer UID '
829
                        f"rekordu JUŻ ISTNIEJĄCEGO W BAZIE DANYCH BPP, a konkretnie {istniejace_rekordy.all()}. "
830
                        f"Z przyczyn oczywistych NIE MOGĘ ustawić takiego PBN UID gdyż wówczas unikalność numerów PBN "
831
                        f"UID byłaby naruszona. Zapewne też doszło do "
832
                        f"NADPISANIA danych w/wym rekordu po stronie PBNu. Powinieneś/aś wycofać zmiany w PBNie "
833
                        f"za pomocą GUI, zgłosić tą sytuację do administratora oraz zaprzestać prób wysyłki "
834
                        f"tego rekordu do wyjaśnienia. "
835
                    )
836

837
                message = (
×
838
                    f"Zarejestrowano ustawienie nowo wysłanej pracy ISTNIEJĄCEGO JUŻ W BAZIE PBN UID\n"
839
                    f"Publikacja:\n{pub}\n\n"
840
                    f"UIDu {ret['objectId']}\n"
841
                    f"Istniejąca praca/e: {istniejace_rekordy.all()}"
842
                )
843

844
                try:
×
845
                    raise PBNUIDSetToExistentException(message)
×
846
                except PBNUIDSetToExistentException as e:
×
847
                    capture_exception(e)
×
848

849
                mail_admins(
×
850
                    "Ustawienie ISTNIEJĄCEGO JUŻ W BAZIE PBN UID publikacji przez serwer PBN",
851
                    message,
852
                    fail_silently=True,
853
                )
854

855
                # NIE zapisuj takiego numeru PBN
856
                return
×
857

858
            pub.pbn_uid = publication
×
859
            pub.save()
×
860

861
    def eventually_coerce_to_publication(self, pub: Model | str) -> Model:
2✔
862
        if type(pub) is str:
×
863
            # Ciag znaków w postaci wydawnictwo_zwarte:123 pozwoli na podawanie tego
864
            # parametru do wywołań z linii poleceń
865
            model, pk = pub.split(":")
×
866
            ctype = ContentType.objects.get(app_label="bpp", model=model)
×
867
            pub = ctype.model_class().objects.get(pk=pk)
×
868

869
        return pub
×
870

871
    def upload_publication_fee(self, pub: Model):
2✔
872
        pub = self.eventually_coerce_to_publication(pub)
×
873
        if pub.pbn_uid_id is None:
×
874
            raise NoPBNUIDException(
×
875
                f"PBN UID (czyli 'numer odpowiednika w PBN') dla rekordu '{pub}' jest pusty."
876
            )
877

878
        fee = OplataZaWydawnictwoPBNAdapter(pub).pbn_get_json()
×
879
        if not fee:
×
880
            raise NoFeeDataException(
×
881
                f"Brak danych o opłatach za publikację {pub.pbn_uid_id}"
882
            )
883

884
        return self.post_publication_fee(pub.pbn_uid_id, fee)
×
885

886
    def exec(self, cmd):
2✔
887
        try:
×
888
            fun = getattr(self, cmd[0])
×
889
        except AttributeError as e:
×
890
            if self._interactive:
×
891
                print("No such command: %s" % cmd)
×
892
                return
×
893
            else:
894
                raise e
×
895

896
        def extract_arguments(lst):
×
897
            args = ()
×
898
            kw = {}
×
899
            for elem in lst:
×
900
                if elem.find(":") >= 1:
×
901
                    k, n = elem.split(":", 1)
×
902
                    kw[k] = n
×
903
                else:
904
                    args += (elem,)
×
905

906
            return args, kw
×
907

908
        args, kw = extract_arguments(cmd[1:])
×
909
        res = fun(*args, **kw)
×
910

911
        if not sys.stdout.isatty():
×
912
            # Non-interactive mode, just output the json
913
            import json
×
914

915
            print(json.dumps(res))
×
916
        else:
917
            if type(res) is dict:
×
918
                pprint(res)
×
919
            elif is_iterable(res):
×
920
                if self._interactive and hasattr(res, "total_elements"):
×
921
                    print(
×
922
                        "Incoming data: no_elements=",
923
                        res.total_elements,
924
                        "no_pages=",
925
                        res.total_pages,
926
                    )
927
                    input("Press ENTER to continue> ")
×
928
                for elem in res:
×
929
                    pprint(elem)
×
930

931
    @transaction.atomic
2✔
932
    def download_disciplines(self):
2✔
933
        """Zapisuje słownik dyscyplin z API PBN do lokalnej bazy"""
934

935
        for elem in self.get_disciplines():
×
936
            validityDateFrom = elem.get("validityDateFrom", None)
×
937
            validityDateTo = elem.get("validityDateTo", None)
×
938
            uuid = elem["uuid"]
×
939

940
            parent_group, created = DisciplineGroup.objects.update_or_create(
×
941
                uuid=uuid,
942
                defaults={
943
                    "validityDateFrom": validityDateFrom,
944
                    "validityDateTo": validityDateTo,
945
                },
946
            )
947

948
            for discipline in elem["disciplines"]:
×
949
                # print("XXX", discipline["uuid"])
950
                Discipline.objects.update_or_create(
×
951
                    parent_group=parent_group,
952
                    uuid=discipline["uuid"],
953
                    defaults=dict(
954
                        code=discipline["code"],
955
                        name=discipline["name"],
956
                        polonCode=discipline["polonCode"],
957
                        scientificFieldName=discipline["scientificFieldName"],
958
                    ),
959
                )
960

961
    @transaction.atomic
2✔
962
    def sync_disciplines(self):
2✔
963
        self.download_disciplines()
×
964
        try:
×
965
            cur_dg = DisciplineGroup.objects.get_current()
×
966
        except DisciplineGroup.DoesNotExist:
×
967
            raise ValueError(
×
968
                "Brak aktualnego słownika dyscyplin na serwerze. Pobierz aktualny słownik "
969
                "dyscyplin z PBN."
970
            )
971

972
        from bpp.models import Dyscyplina_Naukowa
×
973

974
        for dyscyplina in Dyscyplina_Naukowa.objects.all():
×
975
            wpis_tlumacza = TlumaczDyscyplin.objects.get_or_create(
×
976
                dyscyplina_w_bpp=dyscyplina
977
            )[0]
978

979
            wpis_tlumacza.pbn_2024_now = matchuj_aktualna_dyscypline_pbn(
×
980
                dyscyplina.kod, dyscyplina.nazwa
981
            )
982
            # Domyślnie szuka dla lat 2018-2022
983
            wpis_tlumacza.pbn_2017_2021 = matchuj_nieaktualna_dyscypline_pbn(
×
984
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2018, rok_max=2022
985
            )
986

987
            wpis_tlumacza.pbn_2022_2023 = matchuj_nieaktualna_dyscypline_pbn(
×
988
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2023, rok_max=2024
989
            )
990

991
            wpis_tlumacza.save()
×
992

993
        for discipline in cur_dg.discipline_set.all():
×
994
            if discipline.name == "weterynaria":
×
995
                pass
×
996
            # Każda dyscyplina z aktualnego słownika powinna być wpisana do systemu BPP
997
            try:
×
998
                TlumaczDyscyplin.objects.get(pbn_2024_now=discipline)
×
999
            except TlumaczDyscyplin.DoesNotExist:
×
1000
                try:
×
1001
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.get(
×
1002
                        kod=normalize_kod_dyscypliny(discipline.code)
1003
                    )
1004
                    TlumaczDyscyplin.objects.get_or_create(
×
1005
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1006
                    )
1007

1008
                except Dyscyplina_Naukowa.DoesNotExist:
×
1009
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.create(
×
1010
                        kod=normalize_kod_dyscypliny(discipline.code),
1011
                        nazwa=discipline.name,
1012
                    )
1013
                    TlumaczDyscyplin.objects.get_or_create(
×
1014
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1015
                    )
1016

1017
    def interactive(self):
2✔
1018
        self._interactive = True
×
1019
        while True:
×
1020
            cmd = input("cmd> ")
×
1021
            if cmd == "exit":
×
1022
                break
×
1023
            self.exec(cmd.split(" "))
×
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

© 2026 Coveralls, Inc