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

iplweb / bpp / 2314acb3-6be8-4e0c-96b0-e8b120678896

11 Aug 2025 11:35AM UTC coverage: 39.858% (-6.2%) from 46.068%
2314acb3-6be8-4e0c-96b0-e8b120678896

push

circleci

mpasternak
Merge branch 'release/v202508.1185'

8 of 64 new or added lines in 8 files covered. (12.5%)

683 existing lines in 32 files now uncovered.

15116 of 37925 relevant lines covered (39.86%)

0.4 hits per line

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

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

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

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

65
from django.contrib.contenttypes.models import ContentType
1✔
66

67
from django.utils.itercompat import is_iterable
1✔
68

69

70
def smart_content(content):
1✔
71
    try:
×
72
        return content.decode("utf-8")
×
73
    except UnicodeDecodeError:
×
74
        return content
×
75

76

77
class PBNClientTransport:
1✔
78
    def __init__(self, app_id, app_token, base_url, user_token=None):
1✔
79
        self.app_id = app_id
×
80
        self.app_token = app_token
×
81

82
        self.base_url = base_url
×
83
        if self.base_url is None:
×
84
            self.base_url = DEFAULT_BASE_URL
×
85

86
        self.access_token = user_token
×
87

88

89
class PageableResource:
1✔
90
    def __init__(self, transport, res, url, headers, body=None, method="get"):
1✔
91
        self.url = url
×
92
        self.headers = headers
×
93
        self.transport = transport
×
94
        self.body = body
×
95
        self.method = getattr(transport, method)
×
96

97
        try:
×
98
            self.page_0 = res["content"]
×
99
        except KeyError:
×
100
            self.page_0 = []
×
101

102
        self.current_page = res["number"]
×
103
        self.total_elements = res["totalElements"]
×
104
        self.total_pages = res["totalPages"]
×
105
        self.done = False
×
106

107
    def count(self):
1✔
108
        return self.total_elements
×
109

110
    def fetch_page(self, current_page):
1✔
111
        if current_page == 0:
×
112
            return self.page_0
×
113
        # print(f"FETCH {current_page}")
114

115
        kw = {"headers": self.headers}
×
116
        if self.body:
×
117
            kw["body"] = self.body
×
118

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

122
        try:
×
123
            return ret["content"]
×
124
        except KeyError:
×
125
            return
×
126

127
    def __iter__(self):
1✔
128
        for n in range(0, self.total_pages):
×
129
            yield from self.fetch_page(n)
×
130

131

132
class OAuthMixin:
1✔
133
    @classmethod
1✔
134
    def get_auth_url(klass, base_url, app_id):
1✔
135
        return f"{base_url}/auth/pbn/api/registration/user/token/{app_id}"
×
136

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

156
            raise AuthenticationResponseError(response.content)
×
157

158
        return response.json().get("X-User-Token")
×
159

160
    def authorize(self, base_url, app_id, app_token):
1✔
161
        from pbn_api.conf import settings
×
162

163
        if self.access_token:
×
164
            return True
×
165

166
        self.access_token = getattr(settings, "PBN_CLIENT_USER_TOKEN", None)
×
167
        if self.access_token:
×
168
            return True
×
169

170
        auth_url = OAuthMixin.get_auth_url(base_url, app_id)
×
171

172
        print(
×
173
            f"""I have launched a web browser with {auth_url} ,\nplease log-in,
174
             then paste the redirected URL below. \n"""
175
        )
176
        import webbrowser
×
177

178
        webbrowser.open(auth_url)
×
179
        redirect_response = input("Paste the full redirect URL here:")
×
180
        one_time_token = parse_qs(urlparse(redirect_response).query).get("ott")[0]
×
181
        print("ONE TIME TOKEN", one_time_token)
×
182

183
        self.access_token = OAuthMixin.get_user_token(
×
184
            base_url, app_id, app_token, one_time_token
185
        )
186

187
        print("ACCESS TOKEN", self.access_token)
×
188
        return True
×
189

190

191
class RequestsTransport(OAuthMixin, PBNClientTransport):
1✔
192
    def get(self, url, headers=None, fail_on_auth_missing=False):
1✔
193
        sent_headers = {"X-App-Id": self.app_id, "X-App-Token": self.app_token}
×
194
        if self.access_token:
×
195
            sent_headers["X-User-Token"] = self.access_token
×
196

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

202
        if headers is not None:
×
203
            sent_headers.update(headers)
×
204

205
        retries = 0
×
206
        MAX_RETRIES = 15
×
207

208
        while retries < MAX_RETRIES:
×
209
            try:
×
210
                ret = requests.get(self.base_url + url, headers=sent_headers)
×
211
                break
×
212
            except (SSLError, ConnectionError) as e:
×
213
                retries += 1
×
214
                time.sleep(random.randint(1, 5))
×
215
                if retries >= MAX_RETRIES:
×
216
                    raise e
×
217

218
        if ret.status_code == 403:
×
219
            if fail_on_auth_missing:
×
220
                raise AccessDeniedException(url, smart_content(ret.content))
×
221
            # Needs auth
222
            if ret.json()["message"] == "Access Denied":
×
223
                # Autoryzacja użytkownika jest poprawna, jednakże nie ma on po stronie PBN
224
                # takiego uprawnienia...
225
                raise AccessDeniedException(url, smart_content(ret.content))
×
226

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

229
            if hasattr(self, "authorize"):
×
230
                ret = self.authorize(self.base_url, self.app_id, self.app_token)
×
231
                if not ret:
×
232
                    return
×
233

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

237
        if ret.status_code >= 400:
×
238
            raise HttpException(ret.status_code, url, smart_content(ret.content))
×
239

240
        try:
×
241
            return ret.json()
×
242
        except (RequestsJSONDecodeError, JSONDecodeError) as e:
×
243
            if ret.status_code == 200 and b"prace serwisowe" in ret.content:
×
244
                # open("pbn_client_dump.html", "wb").write(ret.content)
245
                raise PraceSerwisoweException()
×
246
            raise e
×
247

248
    def post(self, url, headers=None, body=None, delete=False):
1✔
249
        if not hasattr(self, "access_token"):
×
250
            ret = self.authorize(self.base_url, self.app_id, self.app_token)
×
251
            if not ret:
×
252
                return
×
253
            return self.post(url, headers=headers, body=body, delete=delete)
×
254

255
        sent_headers = {
×
256
            "X-App-Id": self.app_id,
257
            "X-App-Token": self.app_token,
258
            "X-User-Token": self.access_token,
259
        }
260

261
        if headers is not None:
×
262
            sent_headers.update(headers)
×
263

264
        method = requests.post
×
265
        if delete:
×
266
            method = requests.delete
×
267

268
        ret = method(self.base_url + url, headers=sent_headers, json=body)
×
269

270
        # if ret.status_code != 200:
271
        #     pprint(sent_headers)
272
        #     pprint(body)
273

274
        if ret.status_code == 403:
×
275
            try:
×
276
                ret_json = ret.json()
×
277
            except BaseException:
×
278
                raise HttpException(
×
279
                    ret.status_code,
280
                    url,
281
                    "Blad podczas odkodowywania JSON podczas odpowiedzi 403: "
282
                    + smart_content(ret.content),
283
                )
284

285
            # Needs auth
286
            if ret_json.get("message") == "Access Denied":
×
287
                # Autoryzacja użytkownika jest poprawna, jednakże nie ma on po stronie PBN
288
                # takiego uprawnienia...
289
                raise AccessDeniedException(url, smart_content(ret.content))
×
290

291
            if ret_json.get("message") == "Forbidden" and ret_json.get(
×
292
                "description"
293
            ).startswith(NEEDS_PBN_AUTH_MSG):
294
                # (403, '/api/v1/search/publications?size=10', '{"code":403,"message":"Forbidden",
295
                # "description":"W celu poprawnej autentykacji należy podać poprawny token użytkownika aplikacji. Podany
296
                # token użytkownika ... w ramach aplikacji ... nie istnieje lub został
297
                # unieważniony!"}')
298
                raise NeedsPBNAuthorisationException(
×
299
                    ret.status_code, url, smart_content(ret.content)
300
                )
301

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

311
        # mpasternak 7.09.2021, poniżej "przymiarki" do analizowania zwróconych błędów z PBN
312
        #
313
        # if ret.status_code == 400:
314
        #     try:
315
        #         ret_json = ret.json()
316
        #     except BaseException:
317
        #         raise HttpException(
318
        #             ret.status_code,
319
        #             url,
320
        #             "Blad podczas odkodowywania JSON podczas odpowiedzi 400: "
321
        #             + smart_content(ret.content),
322
        #         )
323
        #     if ret_json.get("message") == "Bad Request" and ret_json.get("description") == "Validation failed."
324
        #     and ret_json.get("details")
325
        #
326
        #     HttpException(400, '/api/v1/publications',
327
        #                   '{"code":400,"message":"Bad Request","description":"Validation failed.",
328
        #                   "details":{"doi":"DOI jest błędny lub nie udało się pobrać informacji z serwisu DOI!"}}')
329

330
        if ret.status_code >= 400:
×
331
            if ret.status_code == 423 and smart_content(ret.content) == "Locked":
×
332
                raise ResourceLockedException(
×
333
                    ret.status_code, url, smart_content(ret.content)
334
                )
335

336
            raise HttpException(ret.status_code, url, smart_content(ret.content))
×
337

338
        try:
×
339
            return ret.json()
×
340
        except (RequestsJSONDecodeError, JSONDecodeError) as e:
×
341
            if ret.status_code == 200:
×
342
                if ret.content == b"":
×
343
                    return
×
344

345
                if b"prace serwisowe" in ret.content:
×
346
                    # open("pbn_client_dump.html", "wb").write(ret.content)
347
                    raise PraceSerwisoweException()
×
348

349
            raise e
×
350

351
    def delete(
1✔
352
        self,
353
        url,
354
        headers=None,
355
        body=None,
356
    ):
357
        return self.post(url, headers, body, delete=True)
×
358

359
    def _pages(self, method, url, headers=None, body=None, page_size=10, *args, **kw):
1✔
360
        # Stronicowanie zwraca rezultaty w taki sposób:
361
        # {'content': [{'mongoId': '5e709189878c28a04737dc6f',
362
        #               'status': 'ACTIVE',
363
        # ...
364
        #              'versionHash': '---'}]}],
365
        #  'first': True,
366
        #  'last': False,
367
        #  'number': 0,
368
        #  'numberOfElements': 10,
369
        #  'pageable': {'offset': 0,
370
        #               'pageNumber': 0,
371
        #               'pageSize': 10,
372
        #               'paged': True,
373
        #               'sort': {'sorted': False, 'unsorted': True},
374
        #               'unpaged': False},
375
        #  'size': 10,
376
        #  'sort': {'sorted': False, 'unsorted': True},
377
        #  'totalElements': 68577,
378
        #  'totalPages': 6858}
379

380
        chr = "?"
×
381
        if url.find("?") >= 0:
×
382
            chr = "&"
×
383

384
        url = url + f"{chr}size={page_size}"
×
385
        chr = "&"
×
386

387
        for elem in kw:
×
388
            url += chr + elem + "=" + quote(kw[elem])
×
389

390
        method_function = getattr(self, method)
×
391

392
        if method == "get":
×
393
            res = method_function(url, headers)
×
394
        elif method == "post":
×
395
            res = method_function(url, headers, body=body)
×
396
        else:
397
            raise NotImplementedError
×
398

399
        if "pageable" not in res:
×
400
            warnings.warn(
×
401
                f"PBNClient.{method}_page request for {url} with headers {headers} did not return a paged resource, "
402
                f"maybe use PBNClient.{method} (without 'page') instead",
403
                RuntimeWarning,
404
            )
405
            return res
×
406
        return PageableResource(
×
407
            self, res, url=url, headers=headers, body=body, method=method
408
        )
409

410
    def get_pages(self, url, headers=None, page_size=10, *args, **kw):
1✔
411
        return self._pages(
×
412
            "get", url=url, headers=headers, page_size=page_size, *args, **kw
413
        )
414

415
    def post_pages(self, url, headers=None, body=None, page_size=10, *args, **kw):
1✔
416
        # Jak get_pages, ale methoda to post
417
        if body is None:
×
418
            body = kw
×
419

420
        return self._pages(
×
421
            "post",
422
            url=url,
423
            headers=headers,
424
            body=body,
425
            page_size=page_size,
426
            *args,
427
            **kw,
428
        )
429

430

431
class ConferencesMixin:
1✔
432
    def get_conferences(self, *args, **kw):
1✔
433
        return self.transport.get_pages("/api/v1/conferences/page", *args, **kw)
×
434

435
    def get_conferences_mnisw(self, *args, **kw):
1✔
436
        return self.transport.get_pages("/api/v1/conferences/mnisw/page", *args, **kw)
×
437

438
    def get_conference(self, id):
1✔
439
        return self.transport.get(f"/api/v1/conferences/{id}")
×
440

441
    def get_conference_editions(self, id):
1✔
442
        return self.transport.get(f"/api/v1/conferences/{id}/editions")
×
443

444
    def get_conference_metadata(self, id):
1✔
445
        return self.transport.get(f"/api/v1/conferences/{id}/metadata")
×
446

447

448
class DictionariesMixin:
1✔
449
    def get_countries(self):
1✔
450
        return self.transport.get("/api/v1/dictionary/countries")
×
451
        return self.transport.get("/api/v1/dictionary/countries")
452

453
    def get_disciplines(self):
1✔
454
        return self.transport.get(PBN_GET_DISCIPLINES_URL)
×
455

456
    def get_languages(self):
1✔
457
        return self.transport.get(PBN_GET_LANGUAGES_URL)
×
458

459

460
class InstitutionsMixin:
1✔
461
    def get_institutions(self, status="ACTIVE", *args, **kw):
1✔
462
        return self.transport.get_pages(
×
463
            "/api/v1/institutions/page", status=status, *args, **kw
464
        )
465

466
    def get_institution_by_id(self, id):
1✔
467
        return self.transport.get(f"/api/v1/institutions/{id}")
×
468

469
    def get_institution_by_version(self, version):
1✔
470
        return self.transport.get_pages(f"/api/v1/institutions/version/{version}")
×
471

472
    def get_institution_metadata(self, id):
1✔
473
        return self.transport.get_pages(f"/api/v1/institutions/{id}/metadata")
×
474

475
    def get_institutions_polon(self, includeAllVersions="true", *args, **kw):
1✔
476
        return self.transport.get_pages(
×
477
            "/api/v1/institutions/polon/page",
478
            includeAllVersions=includeAllVersions,
479
            *args,
480
            **kw,
481
        )
482

483
    def get_institutions_polon_by_uid(self, uid):
1✔
484
        return self.transport.get(f"/api/v1/institutions/polon/uid/{uid}")
×
485

486
    def get_institutions_polon_by_id(self, id):
1✔
487
        return self.transport.get(f"/api/v1/institutions/polon/{id}")
×
488

489

490
class InstitutionsProfileMixin:
1✔
491
    def get_institution_publications(self, page_size=10) -> PageableResource:
1✔
492
        return self.transport.get_pages(
×
493
            "/api/v1/institutionProfile/publications/page", page_size=page_size
494
        )
495

496
    def get_institution_publications_v2(
1✔
497
        self,
498
    ) -> PageableResource:
499
        return self.transport.get_pages(PBN_GET_INSTITUTION_PUBLICATIONS_V2)
×
500

501
    def get_institution_statements(self, page_size=10):
1✔
502
        return self.transport.get_pages(
×
503
            PBN_GET_INSTITUTION_STATEMENTS,
504
            page_size=page_size,
505
        )
506

507
    def get_institution_statements_of_single_publication(
1✔
508
        self, pbn_uid_id, page_size=50
509
    ):
510
        return self.transport.get_pages(
×
511
            PBN_GET_INSTITUTION_STATEMENTS + "?publicationId=" + pbn_uid_id,
512
            page_size=page_size,
513
        )
514

515
    def get_institution_publication_v2(
1✔
516
        self,
517
        objectId,
518
    ):
NEW
519
        return self.transport.get_pages(
×
520
            PBN_GET_INSTITUTION_PUBLICATIONS_V2 + f"?publicationId={objectId}",
521
        )
522

523
    def delete_all_publication_statements(self, publicationId):
1✔
524
        url = PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=publicationId)
×
525
        try:
×
526
            return self.transport.delete(
×
527
                url,
528
                body={"all": True, "statementsOfPersons": []},
529
            )
530
        except HttpException as e:
×
531
            if e.status_code != 400 or not e.url.startswith(url):
×
532
                raise e
×
533
            try:
×
534
                ret_json = json.loads(e.content)
×
535
            except BaseException:
×
536
                raise e
×
537

538
            if (
×
539
                ret_json.get("description") != "Validation failed."
540
                or e.content.find("Nie można usunąć oświadczeń") < 0
541
            ):
542
                raise e
×
543

544
            raise CannotDeleteStatementsException(e.content)
×
545

546
    def delete_publication_statement(self, publicationId, personId, role):
1✔
547
        return self.transport.delete(
×
548
            PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=publicationId),
549
            body={"statementsOfPersons": [{"personId": personId, "role": role}]},
550
        )
551

552
    def post_discipline_statements(self, statements_data):
1✔
553
        """
554
        Send discipline statements to PBN API.
555

556
        Args:
557
            statements_data (list): List of statement dictionaries containing discipline information
558

559
        Returns:
560
            dict: Response from PBN API
561
        """
562
        return self.transport.post(
×
563
            PBN_POST_INSTITUTION_STATEMENTS_URL, body=statements_data
564
        )
565

566

567
class JournalsMixin:
1✔
568
    def get_journals_mnisw(self, *args, **kw):
1✔
569
        return self.transport.get_pages("/api/v1/journals/mnisw/page", *args, **kw)
×
570

571
    def get_journals_mnisw_v2(self, *args, **kw):
1✔
572
        return self.transport.get_pages("/api/v2/journals/mnisw/page", *args, **kw)
×
573

574
    def get_journals(self, *args, **kw):
1✔
575
        return self.transport.get_pages("/api/v1/journals/page", *args, **kw)
×
576

577
    def get_journals_v2(self, *args, **kw):
1✔
578
        return self.transport.get_pages("/api/v2/journals/page", *args, **kw)
×
579

580
    def get_journal_by_version(self, version):
1✔
581
        return self.transport.get(f"/api/v1/journals/version/{version}")
×
582

583
    def get_journal_by_id(self, id):
1✔
584
        return self.transport.get(PBN_GET_JOURNAL_BY_ID.format(id=id))
×
585

586
    def get_journal_metadata(self, id):
1✔
587
        return self.transport.get(f"/api/v1/journals/{id}/metadata")
×
588

589

590
class PersonMixin:
1✔
591
    def get_people_by_institution_id(self, id):
1✔
592
        return self.transport.get(f"/api/v1/person/institution/{id}")
×
593

594
    def get_person_by_natural_id(self, id):
1✔
595
        return self.transport.get(f"/api/v1/person/natural/{id}")
×
596

597
    def get_person_by_orcid(self, orcid):
1✔
598
        return self.transport.get(f"/api/v1/person/orcid/{orcid}")
×
599

600
    def get_people(self, *args, **kw):
1✔
601
        return self.transport.get_pages("/api/v1/person/page", *args, **kw)
×
602

603
    def get_person_by_polon_uid(self, uid):
1✔
604
        return self.transport.get(f"/api/v1/person/polon/{uid}")
×
605

606
    def get_person_by_version(self, version):
1✔
607
        return self.transport.get(f"/api/v1/person/version/{version}")
×
608

609
    def get_person_by_id(self, id):
1✔
610
        return self.transport.get(f"/api/v1/person/{id}")
×
611

612

613
class PublishersMixin:
1✔
614
    def get_publishers_mnisw(self, *args, **kw):
1✔
615
        return self.transport.get_pages("/api/v1/publishers/mnisw/page", *args, **kw)
×
616

617
    def get_publishers_mnisw_yearlist(self, *args, **kw):
1✔
618
        return self.transport.get_pages(
×
619
            "/api/v1/publishers/mnisw/page/yearlist", *args, **kw
620
        )
621

622
    def get_publishers(self, *args, **kw):
1✔
623
        return self.transport.get_pages("/api/v1/publishers/page", *args, **kw)
×
624

625
    def get_publisher_by_version(self, version):
1✔
626
        return self.transport.get(f"/api/v1/publishers/version/{version}")
×
627

628
    def get_publisher_by_id(self, id):
1✔
629
        return self.transport.get(f"/api/v1/publishers/{id}")
×
630

631
    def get_publisher_metadata(self, id):
1✔
632
        return self.transport.get(f"/api/v1/publishers/{id}/metadata")
×
633

634

635
class PublicationsMixin:
1✔
636
    def get_publication_by_doi(self, doi):
1✔
637
        return self.transport.get(
×
638
            f"/api/v1/publications/doi/?doi={quote(doi, safe='')}",
639
        )
640

641
    def get_publication_by_doi_page(self, doi):
1✔
642
        return self.transport.get_pages(
×
643
            f"/api/v1/publications/doi/page?doi={quote(doi, safe='')}",
644
            headers={"doi": doi},
645
        )
646

647
    def get_publication_by_id(self, id):
1✔
648
        return self.transport.get(PBN_GET_PUBLICATION_BY_ID_URL.format(id=id))
×
649

650
    def get_publication_metadata(self, id):
1✔
651
        return self.transport.get(f"/api/v1/publications/id/{id}/metadata")
×
652

653
    def get_publications(self, **kw):
1✔
654
        return self.transport.get_pages("/api/v1/publications/page", **kw)
×
655

656
    def get_publication_by_version(self, version):
1✔
657
        return self.transport.get(f"/api/v1/publications/version/{version}")
×
658

659

660
class SearchMixin:
1✔
661
    def search_publications(self, *args, **kw):
1✔
662
        return self.transport.post_pages(PBN_SEARCH_PUBLICATIONS_URL, body=kw)
×
663

664

665
class PBNClient(
1✔
666
    ConferencesMixin,
667
    DictionariesMixin,
668
    InstitutionsMixin,
669
    InstitutionsProfileMixin,
670
    JournalsMixin,
671
    PersonMixin,
672
    PublicationsMixin,
673
    PublishersMixin,
674
    SearchMixin,
675
):
676
    _interactive = False
1✔
677

678
    def __init__(self, transport: RequestsTransport):
1✔
679
        self.transport = transport
×
680

681
    def post_publication(self, json):
1✔
682
        return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json)
×
683

684
    def post_publication_fee(self, publicationId, json):
1✔
685
        return self.transport.post(
×
686
            PBN_POST_PUBLICATION_FEE_URL.format(id=publicationId), body=json
687
        )
688

689
    def get_publication_fee(self, publicationId):
1✔
690
        res = self.transport.post_pages(
×
691
            "/api/v1/institutionProfile/publications/search/fees",
692
            body={"publicationIds": [str(publicationId)]},
693
        )
694
        if not res.count():
×
695
            return
×
696
        elif res.count() == 1:
×
697
            return list(res)[0]
×
698
        else:
699
            raise NotImplementedError("count > 1")
×
700

701
    def upload_publication(
1✔
702
        self, rec, force_upload=False, export_pk_zero=None, always_affiliate_to_uid=None
703
    ):
704
        js = WydawnictwoPBNAdapter(
×
705
            rec,
706
            export_pk_zero=export_pk_zero,
707
            always_affiliate_to_uid=always_affiliate_to_uid,
708
        ).pbn_get_json()
709
        if not force_upload:
×
710
            needed = SentData.objects.check_if_needed(rec, js)
×
711
            if not needed:
×
712
                raise SameDataUploadedRecently(
×
713
                    SentData.objects.get_for_rec(rec).last_updated_on
714
                )
715
        try:
×
716
            ret = self.post_publication(js)
×
717
        except Exception as e:
×
718
            SentData.objects.updated(rec, js, uploaded_okay=False, exception=str(e))
×
719
            raise e
×
720

721
        return ret, js
×
722

723
    def download_publication(self, doi=None, objectId=None):
1✔
724
        from .integrator import zapisz_mongodb
×
725
        from .models import Publication
×
726

727
        assert doi or objectId
×
728

729
        if doi:
×
730
            data = self.get_publication_by_doi(doi)
×
731
        elif objectId:
×
732
            data = self.get_publication_by_id(objectId)
×
733

734
        return zapisz_mongodb(data, Publication)
×
735

736
    @transaction.atomic
1✔
737
    def download_statements_of_publication(self, pub):
1✔
738
        from pbn_api.models import OswiadczenieInstytucji
×
739
        from .integrator import pobierz_mongodb, zapisz_oswiadczenie_instytucji
×
740

741
        OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk).delete()
×
742

743
        pobierz_mongodb(
×
744
            self.get_institution_statements_of_single_publication(pub.pk, 5120),
745
            None,
746
            fun=zapisz_oswiadczenie_instytucji,
747
            client=self,
748
            disable_progress_bar=True,
749
        )
750

751
    def pobierz_publikacje_instytucji_v2(self, objectId):
1✔
NEW
752
        from pbn_api.integrator import zapisz_publikacje_instytucji_v2
×
753

NEW
754
        elem = list(self.get_institution_publication_v2(objectId=objectId))
×
NEW
755
        if not elem:
×
NEW
756
            raise PublikacjaInstytucjiV2NieZnalezionaException(objectId)
×
757

NEW
758
        if len(elem) != 1:
×
NEW
759
            raise ZnalezionoWielePublikacjiInstytucjiV2Exception(objectId)
×
760

NEW
761
        return zapisz_publikacje_instytucji_v2(self, elem[0])
×
762

763
    def sync_publication(
1✔
764
        self,
765
        pub,
766
        notificator=None,
767
        force_upload=False,
768
        delete_statements_before_upload=False,
769
        export_pk_zero=None,
770
        always_affiliate_to_uid=None,
771
    ):
772
        """
773
        @param delete_statements_before_upload: gdy True, kasuj oświadczenia publikacji przed wysłaniem (jeżeli posiada
774
        PBN UID)
775
        """
776

777
        # if not pub.doi:
778
        #     raise WillNotExportError("Ustaw DOI dla publikacji")
779

780
        pub = self.eventually_coerce_to_publication(pub)
×
781

782
        #
783
        if (
×
784
            delete_statements_before_upload
785
            and hasattr(pub, "pbn_uid_id")
786
            and pub.pbn_uid_id is not None
787
        ):
788
            try:
×
789
                self.delete_all_publication_statements(pub.pbn_uid_id)
×
790

791
                # Jeżeli zostały skasowane dane, to wymuś wysłanie rekordu, niezależnie
792
                # od stanu tabeli SentData
793
                force_upload = True
×
794
            except HttpException as e:
×
795
                NIE_ISTNIEJA = "Nie istnieją oświadczenia dla publikacji"
×
796

797
                ignored_exception = False
×
798

799
                if e.status_code == 400:
×
800
                    if e.json:
×
801
                        try:
×
802
                            try:
×
803
                                msg = e.json["details"]["publicationId"]
×
804
                            except KeyError:
×
805
                                msg = e.json["details"][
×
806
                                    f"publicationId.{pub.pbn_uid_id}"
807
                                ]
808
                            if NIE_ISTNIEJA in msg:
×
809
                                ignored_exception = True
×
810
                        except (TypeError, KeyError):
×
811
                            if NIE_ISTNIEJA in e.content:
×
812
                                ignored_exception = True
×
813

814
                    else:
815
                        if NIE_ISTNIEJA in e.content:
×
816
                            ignored_exception = True
×
817

818
                if not ignored_exception:
×
819
                    raise e
×
820

821
        # Wgraj dane do PBN
822
        ret, js = self.upload_publication(
×
823
            pub,
824
            force_upload=force_upload,
825
            export_pk_zero=export_pk_zero,
826
            always_affiliate_to_uid=always_affiliate_to_uid,
827
        )
828

829
        if not ret.get("objectId", ""):
×
830
            msg = (
×
831
                f"UWAGA. Serwer PBN nie odpowiedział prawidłowym PBN UID dla"
832
                f" wysyłanego rekordu. Zgłoś sytuację do administratora serwisu. "
833
                f"{ret=}, {js=}, {pub=}"
834
            )
835
            if notificator is not None:
×
836
                notificator.error(msg)
×
837

838
            try:
×
839
                raise NoPBNUIDException(msg)
×
840
            except NoPBNUIDException as e:
×
841
                capture_exception(e)
×
842

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

845
        # Pobierz zwrotnie dane z PBN
846
        publication = self.download_publication(objectId=ret["objectId"])
×
847
        self.download_statements_of_publication(publication)
×
NEW
848
        self.pobierz_publikacje_instytucji_v2(objectId=ret["objectId"])
×
849

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

857
        if pub.pbn_uid_id != ret["objectId"]:
×
858
            # Rekord dostaje nowe objectId z PBNu.
859

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

870
                message = (
×
871
                    f"Zarejestrowano zmianę ZAPISANEGO WCZEŚNIEJ PBN UID publikacji przez PBN, \n"
872
                    f"Publikacja:\n{pub}\n\n"
873
                    f"z UIDu {pub.pbn_uid_id} na {ret['objectId']}"
874
                )
875

876
                try:
×
877
                    raise PBNUIDChangedException(message)
×
878
                except PBNUIDChangedException as e:
×
879
                    capture_exception(e)
×
880

881
                mail_admins(
×
882
                    "Zmiana PBN UID publikacji przez serwer PBN",
883
                    message,
884
                    fail_silently=True,
885
                )
886

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

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

904
                message = (
×
905
                    f"Zarejestrowano ustawienie nowo wysłanej pracy ISTNIEJĄCEGO JUŻ W BAZIE PBN UID\n"
906
                    f"Publikacja:\n{pub}\n\n"
907
                    f"UIDu {ret['objectId']}\n"
908
                    f"Istniejąca praca/e: {istniejace_rekordy.all()}"
909
                )
910

911
                try:
×
912
                    raise PBNUIDSetToExistentException(message)
×
913
                except PBNUIDSetToExistentException as e:
×
914
                    capture_exception(e)
×
915

916
                mail_admins(
×
917
                    "Ustawienie ISTNIEJĄCEGO JUŻ W BAZIE PBN UID publikacji przez serwer PBN",
918
                    message,
919
                    fail_silently=True,
920
                )
921

922
                # NIE zapisuj takiego numeru PBN
923
                return
×
924

925
            pub.pbn_uid = publication
×
926
            pub.save()
×
927

928
    def eventually_coerce_to_publication(self, pub: Model | str) -> Model:
1✔
929
        if type(pub) is str:
×
930
            # Ciag znaków w postaci wydawnictwo_zwarte:123 pozwoli na podawanie tego
931
            # parametru do wywołań z linii poleceń
932
            model, pk = pub.split(":")
×
933
            ctype = ContentType.objects.get(app_label="bpp", model=model)
×
934
            pub = ctype.model_class().objects.get(pk=pk)
×
935

936
        return pub
×
937

938
    def upload_publication_fee(self, pub: Model):
1✔
939
        pub = self.eventually_coerce_to_publication(pub)
×
940
        if pub.pbn_uid_id is None:
×
941
            raise NoPBNUIDException(
×
942
                f"PBN UID (czyli 'numer odpowiednika w PBN') dla rekordu '{pub}' jest pusty."
943
            )
944

945
        fee = OplataZaWydawnictwoPBNAdapter(pub).pbn_get_json()
×
946
        if not fee:
×
947
            raise NoFeeDataException(
×
948
                f"Brak danych o opłatach za publikację {pub.pbn_uid_id}"
949
            )
950

951
        return self.post_publication_fee(pub.pbn_uid_id, fee)
×
952

953
    def exec(self, cmd):
1✔
954
        try:
×
955
            fun = getattr(self, cmd[0])
×
956
        except AttributeError as e:
×
957
            if self._interactive:
×
958
                print("No such command: %s" % cmd)
×
959
                return
×
960
            else:
961
                raise e
×
962

963
        def extract_arguments(lst):
×
964
            args = ()
×
965
            kw = {}
×
966
            for elem in lst:
×
967
                if elem.find(":") >= 1:
×
968
                    k, n = elem.split(":", 1)
×
969
                    kw[k] = n
×
970
                else:
971
                    args += (elem,)
×
972

973
            return args, kw
×
974

975
        args, kw = extract_arguments(cmd[1:])
×
976
        res = fun(*args, **kw)
×
977

978
        if not sys.stdout.isatty():
×
979
            # Non-interactive mode, just output the json
980
            import json
×
981

982
            print(json.dumps(res))
×
983
        else:
984
            if type(res) is dict:
×
985
                pprint(res)
×
986
            elif is_iterable(res):
×
987
                if self._interactive and hasattr(res, "total_elements"):
×
988
                    print(
×
989
                        "Incoming data: no_elements=",
990
                        res.total_elements,
991
                        "no_pages=",
992
                        res.total_pages,
993
                    )
994
                    input("Press ENTER to continue> ")
×
995
                for elem in res:
×
996
                    pprint(elem)
×
997

998
    @transaction.atomic
1✔
999
    def download_disciplines(self):
1✔
1000
        """Zapisuje słownik dyscyplin z API PBN do lokalnej bazy"""
1001

1002
        for elem in self.get_disciplines():
×
1003
            validityDateFrom = elem.get("validityDateFrom", None)
×
1004
            validityDateTo = elem.get("validityDateTo", None)
×
1005
            uuid = elem["uuid"]
×
1006

1007
            parent_group, created = DisciplineGroup.objects.update_or_create(
×
1008
                uuid=uuid,
1009
                defaults={
1010
                    "validityDateFrom": validityDateFrom,
1011
                    "validityDateTo": validityDateTo,
1012
                },
1013
            )
1014

1015
            for discipline in elem["disciplines"]:
×
1016
                # print("XXX", discipline["uuid"])
1017
                Discipline.objects.update_or_create(
×
1018
                    parent_group=parent_group,
1019
                    uuid=discipline["uuid"],
1020
                    defaults=dict(
1021
                        code=discipline["code"],
1022
                        name=discipline["name"],
1023
                        polonCode=discipline["polonCode"],
1024
                        scientificFieldName=discipline["scientificFieldName"],
1025
                    ),
1026
                )
1027

1028
    @transaction.atomic
1✔
1029
    def sync_disciplines(self):
1✔
1030
        self.download_disciplines()
×
1031
        try:
×
1032
            cur_dg = DisciplineGroup.objects.get_current()
×
1033
        except DisciplineGroup.DoesNotExist:
×
1034
            raise ValueError(
×
1035
                "Brak aktualnego słownika dyscyplin na serwerze. Pobierz aktualny słownik "
1036
                "dyscyplin z PBN."
1037
            )
1038

1039
        from bpp.models import Dyscyplina_Naukowa
×
1040

1041
        for dyscyplina in Dyscyplina_Naukowa.objects.all():
×
1042
            wpis_tlumacza = TlumaczDyscyplin.objects.get_or_create(
×
1043
                dyscyplina_w_bpp=dyscyplina
1044
            )[0]
1045

1046
            wpis_tlumacza.pbn_2024_now = matchuj_aktualna_dyscypline_pbn(
×
1047
                dyscyplina.kod, dyscyplina.nazwa
1048
            )
1049
            # Domyślnie szuka dla lat 2018-2022
1050
            wpis_tlumacza.pbn_2017_2021 = matchuj_nieaktualna_dyscypline_pbn(
×
1051
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2018, rok_max=2022
1052
            )
1053

1054
            wpis_tlumacza.pbn_2022_2023 = matchuj_nieaktualna_dyscypline_pbn(
×
1055
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2023, rok_max=2024
1056
            )
1057

1058
            wpis_tlumacza.save()
×
1059

1060
        for discipline in cur_dg.discipline_set.all():
×
1061
            if discipline.name == "weterynaria":
×
1062
                pass
×
1063
            # Każda dyscyplina z aktualnego słownika powinna być wpisana do systemu BPP
1064
            try:
×
1065
                TlumaczDyscyplin.objects.get(pbn_2024_now=discipline)
×
1066
            except TlumaczDyscyplin.DoesNotExist:
×
1067
                try:
×
1068
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.get(
×
1069
                        kod=normalize_kod_dyscypliny(discipline.code)
1070
                    )
1071
                    TlumaczDyscyplin.objects.get_or_create(
×
1072
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1073
                    )
1074

1075
                except Dyscyplina_Naukowa.DoesNotExist:
×
1076
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.create(
×
1077
                        kod=normalize_kod_dyscypliny(discipline.code),
1078
                        nazwa=discipline.name,
1079
                    )
1080
                    TlumaczDyscyplin.objects.get_or_create(
×
1081
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1082
                    )
1083

1084
    def interactive(self):
1✔
1085
        self._interactive = True
×
1086
        while True:
×
1087
            cmd = input("cmd> ")
×
1088
            if cmd == "exit":
×
1089
                break
×
1090
            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