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

iplweb / bpp / ba6f9e1f-4683-40a1-aae1-40dd0fcb64e3

25 Aug 2025 06:57PM UTC coverage: 43.284% (+0.6%) from 42.715%
ba6f9e1f-4683-40a1-aae1-40dd0fcb64e3

push

circleci

mpasternak
Merge branch 'release/v202508.1208'

77 of 961 new or added lines in 27 files covered. (8.01%)

731 existing lines in 54 files now uncovered.

17273 of 39906 relevant lines covered (43.28%)

0.78 hits per line

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

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

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

20
from import_common.core import (
2✔
21
    matchuj_aktualna_dyscypline_pbn,
22
    matchuj_nieaktualna_dyscypline_pbn,
23
)
24
from import_common.normalization import normalize_kod_dyscypliny
2✔
25
from pbn_api.adapters.wydawnictwo import (
2✔
26
    OplataZaWydawnictwoPBNAdapter,
27
    WydawnictwoPBNAdapter,
28
)
29
from pbn_api.const import (
2✔
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_PUBLICATION_NO_STATEMENTS_URL,
42
    PBN_POST_PUBLICATIONS_URL,
43
    PBN_SEARCH_PUBLICATIONS_URL,
44
)
45
from pbn_api.exceptions import (
2✔
46
    AccessDeniedException,
47
    AuthenticationConfigurationError,
48
    AuthenticationResponseError,
49
    CannotDeleteStatementsException,
50
    HttpException,
51
    NeedsPBNAuthorisationException,
52
    NoFeeDataException,
53
    NoPBNUIDException,
54
    PBNUIDChangedException,
55
    PBNUIDSetToExistentException,
56
    PraceSerwisoweException,
57
    PublikacjaInstytucjiV2NieZnalezionaException,
58
    ResourceLockedException,
59
    SameDataUploadedRecently,
60
    ZnalezionoWielePublikacjiInstytucjiV2Exception,
61
)
62
from pbn_api.models import TlumaczDyscyplin
2✔
63
from pbn_api.models.discipline import Discipline, DisciplineGroup
2✔
64
from pbn_api.models.sentdata import SentData
2✔
65
from pbn_api.utils import rename_dict_key
2✔
66

67
from django.contrib.contenttypes.models import ContentType
2✔
68

69
from django.utils.itercompat import is_iterable
2✔
70

71

72
def smart_content(content):
2✔
73
    try:
×
74
        return content.decode("utf-8")
×
75
    except UnicodeDecodeError:
×
76
        return content
×
77

78

79
class PBNClientTransport:
2✔
80
    def __init__(self, app_id, app_token, base_url, user_token=None):
2✔
81
        self.app_id = app_id
×
82
        self.app_token = app_token
×
83

84
        self.base_url = base_url
×
85
        if self.base_url is None:
×
86
            self.base_url = DEFAULT_BASE_URL
×
87

88
        self.access_token = user_token
×
89

90

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

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

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

109
    def count(self):
2✔
110
        return self.total_elements
×
111

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

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

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

124
        try:
×
125
            return ret["content"]
×
126
        except KeyError:
×
127
            return
×
128

129
    def __iter__(self):
2✔
130
        for n in range(0, self.total_pages):
×
131
            yield from self.fetch_page(n)
×
132

133

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

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

158
            raise AuthenticationResponseError(response.content)
×
159

160
        return response.json().get("X-User-Token")
×
161

162
    def authorize(self, base_url, app_id, app_token):
2✔
163
        from pbn_api.conf import settings
×
164

165
        if self.access_token:
×
166
            return True
×
167

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

172
        auth_url = OAuthMixin.get_auth_url(base_url, app_id)
×
173

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

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

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

189
        print("ACCESS TOKEN", self.access_token)
×
190
        return True
×
191

192

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

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

204
        if headers is not None:
×
205
            sent_headers.update(headers)
×
206

207
        retries = 0
×
208
        MAX_RETRIES = 15
×
209

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

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

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

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

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

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

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

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

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

263
        if headers is not None:
×
264
            sent_headers.update(headers)
×
265

266
        method = requests.post
×
267
        if delete:
×
268
            method = requests.delete
×
269

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

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

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

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

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

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

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

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

338
            raise HttpException(ret.status_code, url, smart_content(ret.content))
×
339

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

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

351
            raise e
×
352

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

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

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

386
        url = url + f"{chr}size={page_size}"
×
387
        chr = "&"
×
388

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

392
        method_function = getattr(self, method)
×
393

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

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

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

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

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

432

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

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

440
    def get_conference(self, id):
2✔
441
        return self.transport.get(f"/api/v1/conferences/{id}")
×
442

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

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

449

450
class DictionariesMixin:
2✔
451
    def get_countries(self):
2✔
452
        return self.transport.get("/api/v1/dictionary/countries")
×
453
        return self.transport.get("/api/v1/dictionary/countries")
454

455
    def get_disciplines(self):
2✔
456
        return self.transport.get(PBN_GET_DISCIPLINES_URL)
×
457

458
    def get_languages(self):
2✔
459
        return self.transport.get(PBN_GET_LANGUAGES_URL)
×
460

461

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

468
    def get_institution_by_id(self, id):
2✔
469
        return self.transport.get(f"/api/v1/institutions/{id}")
×
470

471
    def get_institution_by_version(self, version):
2✔
472
        return self.transport.get_pages(f"/api/v1/institutions/version/{version}")
×
473

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

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

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

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

491

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

498
    def get_institution_publications_v2(
2✔
499
        self,
500
    ) -> PageableResource:
501
        return self.transport.get_pages(PBN_GET_INSTITUTION_PUBLICATIONS_V2)
×
502

503
    def get_institution_statements(self, page_size=10):
2✔
504
        return self.transport.get_pages(
×
505
            PBN_GET_INSTITUTION_STATEMENTS,
506
            page_size=page_size,
507
        )
508

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

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

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

536
            try:
×
537
                ret_json = json.loads(e.content)
×
538
            except BaseException:
×
539
                raise e
×
540

541
            NIE_MOZNA_USUNAC = "Nie można usunąć oświadczeń."
×
542
            NIE_ISTNIEJA = "Nie istnieją oświadczenia dla publikacji"
×
NEW
543
            NIE_ISTNIEJE = "Nie istnieje oświadczenie dla publikacji"
×
544

545
            if ret_json:
×
546
                try:
×
547
                    try:
×
548
                        msg = e.json["details"]["publicationId"]
×
549
                    except KeyError:
×
550
                        msg = e.json["details"][f"publicationId.{publicationId}"]
×
551

NEW
552
                    if (
×
553
                        NIE_ISTNIEJA in msg or NIE_ISTNIEJE in msg
554
                    ) and NIE_MOZNA_USUNAC in msg:
555
                        # Opis odpowiada sytuacji "Nie można usunąć oświadczeń, nie istnieją"
556
                        raise CannotDeleteStatementsException(e.content)
×
557

558
                except (TypeError, KeyError):
×
NEW
559
                    if (
×
560
                        NIE_ISTNIEJA in e.content or NIE_ISTNIEJE in e.content
561
                    ) and NIE_MOZNA_USUNAC in e.content:
UNCOV
562
                        raise CannotDeleteStatementsException(e.content)
×
563

564
            raise e
×
565

566
    def delete_publication_statement(self, publicationId, personId, role):
2✔
567
        return self.transport.delete(
×
568
            PBN_DELETE_PUBLICATION_STATEMENT.format(publicationId=publicationId),
569
            body={"statementsOfPersons": [{"personId": personId, "role": role}]},
570
        )
571

572
    def post_discipline_statements(self, statements_data):
2✔
573
        """
574
        Send discipline statements to PBN API.
575

576
        Args:
577
            statements_data (list): List of statement dictionaries containing discipline information
578

579
        Returns:
580
            dict: Response from PBN API
581
        """
582
        return self.transport.post(
×
583
            PBN_POST_INSTITUTION_STATEMENTS_URL, body=statements_data
584
        )
585

586

587
class JournalsMixin:
2✔
588
    def get_journals_mnisw(self, *args, **kw):
2✔
589
        return self.transport.get_pages("/api/v1/journals/mnisw/page", *args, **kw)
×
590

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

594
    def get_journals(self, *args, **kw):
2✔
595
        return self.transport.get_pages("/api/v1/journals/page", *args, **kw)
×
596

597
    def get_journals_v2(self, *args, **kw):
2✔
598
        return self.transport.get_pages("/api/v2/journals/page", *args, **kw)
×
599

600
    def get_journal_by_version(self, version):
2✔
601
        return self.transport.get(f"/api/v1/journals/version/{version}")
×
602

603
    def get_journal_by_id(self, id):
2✔
604
        return self.transport.get(PBN_GET_JOURNAL_BY_ID.format(id=id))
×
605

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

609

610
class PersonMixin:
2✔
611
    def get_people_by_institution_id(self, id):
2✔
612
        return self.transport.get(f"/api/v1/person/institution/{id}")
×
613

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

617
    def get_person_by_orcid(self, orcid):
2✔
618
        return self.transport.get(f"/api/v1/person/orcid/{orcid}")
×
619

620
    def get_people(self, *args, **kw):
2✔
621
        return self.transport.get_pages("/api/v1/person/page", *args, **kw)
×
622

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

626
    def get_person_by_version(self, version):
2✔
627
        return self.transport.get(f"/api/v1/person/version/{version}")
×
628

629
    def get_person_by_id(self, id):
2✔
630
        return self.transport.get(f"/api/v1/person/{id}")
×
631

632

633
class PublishersMixin:
2✔
634
    def get_publishers_mnisw(self, *args, **kw):
2✔
635
        return self.transport.get_pages("/api/v1/publishers/mnisw/page", *args, **kw)
×
636

637
    def get_publishers_mnisw_yearlist(self, *args, **kw):
2✔
638
        return self.transport.get_pages(
×
639
            "/api/v1/publishers/mnisw/page/yearlist", *args, **kw
640
        )
641

642
    def get_publishers(self, *args, **kw):
2✔
643
        return self.transport.get_pages("/api/v1/publishers/page", *args, **kw)
×
644

645
    def get_publisher_by_version(self, version):
2✔
646
        return self.transport.get(f"/api/v1/publishers/version/{version}")
×
647

648
    def get_publisher_by_id(self, id):
2✔
649
        return self.transport.get(f"/api/v1/publishers/{id}")
×
650

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

654

655
class PublicationsMixin:
2✔
656
    def get_publication_by_doi(self, doi):
2✔
657
        return self.transport.get(
×
658
            f"/api/v1/publications/doi/?doi={quote(doi, safe='')}",
659
        )
660

661
    def get_publication_by_doi_page(self, doi):
2✔
662
        return self.transport.get_pages(
×
663
            f"/api/v1/publications/doi/page?doi={quote(doi, safe='')}",
664
            headers={"doi": doi},
665
        )
666

667
    def get_publication_by_id(self, id):
2✔
668
        return self.transport.get(PBN_GET_PUBLICATION_BY_ID_URL.format(id=id))
×
669

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

673
    def get_publications(self, **kw):
2✔
674
        return self.transport.get_pages("/api/v1/publications/page", **kw)
×
675

676
    def get_publication_by_version(self, version):
2✔
677
        return self.transport.get(f"/api/v1/publications/version/{version}")
×
678

679

680
class SearchMixin:
2✔
681
    def search_publications(self, *args, **kw):
2✔
682
        return self.transport.post_pages(PBN_SEARCH_PUBLICATIONS_URL, body=kw)
×
683

684

685
class PBNClient(
2✔
686
    ConferencesMixin,
687
    DictionariesMixin,
688
    InstitutionsMixin,
689
    InstitutionsProfileMixin,
690
    JournalsMixin,
691
    PersonMixin,
692
    PublicationsMixin,
693
    PublishersMixin,
694
    SearchMixin,
695
):
696
    _interactive = False
2✔
697

698
    def __init__(self, transport: RequestsTransport):
2✔
699
        self.transport = transport
1✔
700

701
    def post_publication(self, json):
2✔
702
        return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json)
×
703

704
    def post_publication_no_statements(self, json):
2✔
705
        """
706
        Ta funkcja służy do wysyłania publikacji BEZ oświadczeń.
707

708
        Bierzemy słownik JSON z publikacji-z-oświadczeniami i przetwarzamy go.
709

710
        :param json:
711
        :return:
712
        """
713

714
        # PBN zmienił givenNames na firstName
715
        for elem in json.get("authors", []):
×
716
            elem["firstName"] = elem.pop("givenNames")
×
717

718
        for elem in json.get("editors", []):
×
719
            elem["firstName"] = elem.pop("givenNames")
×
720

721
        # PBN życzy abstrakty w root
722
        abstracts = json.pop("languageData", {}).get("abstracts", [])
×
723
        if abstracts:
×
724
            json["abstracts"] = abstracts
×
725

726
        # PBN nie życzy opłat
727
        json.pop("fee", None)
×
728

729
        # PBN zmienił nazwę mniswId na ministryId
730
        json = rename_dict_key(json, "mniswId", "ministryId")
×
731

732
        # Można próbować
733
        return self.transport.post(PBN_POST_PUBLICATION_NO_STATEMENTS_URL, body=[json])
×
734

735
    def post_publication_fee(self, publicationId, json):
2✔
736
        return self.transport.post(
×
737
            PBN_POST_PUBLICATION_FEE_URL.format(id=publicationId), body=json
738
        )
739

740
    def get_publication_fee(self, publicationId):
2✔
741
        res = self.transport.post_pages(
×
742
            "/api/v1/institutionProfile/publications/search/fees",
743
            body={"publicationIds": [str(publicationId)]},
744
        )
745
        if not res.count():
×
746
            return
×
747
        elif res.count() == 1:
×
748
            return list(res)[0]
×
749
        else:
750
            raise NotImplementedError("count > 1")
×
751

752
    def upload_publication(
2✔
753
        self, rec, force_upload=False, export_pk_zero=None, always_affiliate_to_uid=None
754
    ):
755
        """
756
        Ta funkcja wysyła dane publikacji na serwer, w zależności od obecności oświadczeń
757
        w JSONie (klucz: "statements") używa albo api /v1/ do wysyłki publikacji "ze wszystkim",
758
        albo korzysta z api /v1/ repozytoryjnego.
759

760
        Zwracane wyniki wyjściowe też różnią się w zależnosci od użytego API stąd też ta funkcja
761
        stara się w miarę rozsądnie to ogarnąć.
762
        """
763

764
        js = WydawnictwoPBNAdapter(
×
765
            rec,
766
            export_pk_zero=export_pk_zero,
767
            always_affiliate_to_uid=always_affiliate_to_uid,
768
        ).pbn_get_json()
769

770
        if not force_upload:
×
771
            needed = SentData.objects.check_if_needed(rec, js)
×
772
            if not needed:
×
773
                raise SameDataUploadedRecently(
×
774
                    SentData.objects.get_for_rec(rec).last_updated_on
775
                )
776

777
        try:
×
778
            if "statements" in js:
×
779
                ret = self.post_publication(js)
×
780
                objectId = ret.get("objectId", None)
×
781
                bez_oswiadczen = False
×
782

783
            else:
784
                ret = self.post_publication_no_statements(js)
×
785

786
                if len(ret) == 1:
×
787
                    try:
×
788
                        objectId = ret[0].get("id", None)
×
789
                    except KeyError:
×
790
                        raise Exception(
×
791
                            f"Serwer zwrócił nieoczekiwaną odpowiedź. {ret=}"
792
                        )
793
                    bez_oswiadczen = True
×
794
                else:
795
                    raise Exception(
×
796
                        "Lista zwróconych obiektów przy wysyłce pracy bez oświadczeń różna od jednego. "
797
                        "Sytuacja nieobsługiwana, proszę o kontakt z autorem programu. "
798
                    )
799

800
        except Exception as e:
×
801
            SentData.objects.updated(rec, js, uploaded_okay=False, exception=str(e))
×
802
            raise e
×
803

804
        return objectId, ret, js, bez_oswiadczen
×
805

806
    def download_publication(self, doi=None, objectId=None):
2✔
807
        from .integrator import zapisz_mongodb
×
808
        from .models import Publication
×
809

810
        assert doi or objectId
×
811

812
        if doi:
×
813
            data = self.get_publication_by_doi(doi)
×
814
        elif objectId:
×
815
            data = self.get_publication_by_id(objectId)
×
816

817
        return zapisz_mongodb(data, Publication)
×
818

819
    @transaction.atomic
2✔
820
    def download_statements_of_publication(self, pub):
2✔
821
        from pbn_api.models import OswiadczenieInstytucji
×
822
        from .integrator import pobierz_mongodb, zapisz_oswiadczenie_instytucji
×
823

824
        OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk).delete()
×
825

826
        pobierz_mongodb(
×
827
            self.get_institution_statements_of_single_publication(pub.pk, 5120),
828
            None,
829
            fun=zapisz_oswiadczenie_instytucji,
830
            client=self,
831
            disable_progress_bar=True,
832
        )
833

834
    def pobierz_publikacje_instytucji_v2(self, objectId):
2✔
835
        from pbn_api.integrator import zapisz_publikacje_instytucji_v2
×
836

837
        elem = list(self.get_institution_publication_v2(objectId=objectId))
×
838
        if not elem:
×
839
            raise PublikacjaInstytucjiV2NieZnalezionaException(objectId)
×
840

841
        if len(elem) != 1:
×
842
            raise ZnalezionoWielePublikacjiInstytucjiV2Exception(objectId)
×
843

844
        return zapisz_publikacje_instytucji_v2(self, elem[0])
×
845

846
    def sync_publication(
2✔
847
        self,
848
        pub,
849
        notificator=None,
850
        force_upload=False,
851
        delete_statements_before_upload=False,
852
        export_pk_zero=None,
853
        always_affiliate_to_uid=None,
854
    ):
855
        """
856
        @param delete_statements_before_upload: gdy True, kasuj oświadczenia publikacji przed wysłaniem (jeżeli posiada
857
        PBN UID)
858
        """
859

860
        # if not pub.doi:
861
        #     raise WillNotExportError("Ustaw DOI dla publikacji")
862

863
        pub = self.eventually_coerce_to_publication(pub)
×
864

865
        #
866
        if (
×
867
            delete_statements_before_upload
868
            and hasattr(pub, "pbn_uid_id")
869
            and pub.pbn_uid_id is not None
870
        ):
871
            try:
×
872
                self.delete_all_publication_statements(pub.pbn_uid_id)
×
873

874
                # Jeżeli zostały skasowane dane, to wymuś wysłanie rekordu, niezależnie
875
                # od stanu tabeli SentData
876
                force_upload = True
×
877
            except CannotDeleteStatementsException:
×
878
                # Ignoruj, jeżeli nie można skasowac oświadczeń publikacji. Mogą nie istnieć
879
                pass
×
880

881
        # Wgraj dane do PBN
882
        objectId, ret, js, bez_oswiadczen = self.upload_publication(
×
883
            pub,
884
            force_upload=force_upload,
885
            export_pk_zero=export_pk_zero,
886
            always_affiliate_to_uid=always_affiliate_to_uid,
887
        )
888

889
        if bez_oswiadczen:
×
890
            if notificator is not None:
×
891
                notificator.info(
×
892
                    "Rekord nie posiada oświadczeń - wysłano wyłącznie do repozytorium PBN. "
893
                )
894

895
        if not objectId:
×
896
            msg = (
×
897
                f"UWAGA. Serwer PBN nie odpowiedział prawidłowym PBN UID dla"
898
                f" wysyłanego rekordu. Zgłoś sytuację do administratora serwisu. "
899
                f"{ret=}, {js=}, {pub=}"
900
            )
901
            if notificator is not None:
×
902
                notificator.error(msg)
×
903

904
            try:
×
905
                raise NoPBNUIDException(msg)
×
906
            except NoPBNUIDException as e:
×
907
                capture_exception(e)
×
908

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

911
            return
×
912

913
        # Pobierz zwrotnie dane z PBN
914
        publication = self.download_publication(objectId=objectId)
×
915

916
        if not bez_oswiadczen:
×
917
            self.download_statements_of_publication(publication)
×
918
            try:
×
919
                self.pobierz_publikacje_instytucji_v2(objectId=objectId)
×
920
            except PublikacjaInstytucjiV2NieZnalezionaException:
×
921
                notificator.warning(
×
922
                    "Nie znaleziono oświadczeń dla publikacji po stronie PBN w wersji V2 API. Ten komunikat nie jest "
923
                    "błędem. "
924
                )
925

926
        # Utwórz obiekt zapisanych danych. Dopiero w tym miejscu, bo jeżeli zostanie
927
        # utworzony nowy rekord po stronie PBN, to pbn_uid_id musi wskazywać na
928
        # bazę w tabeli Publication, która została chwile temu pobrana...
929
        SentData.objects.updated(pub, js, pbn_uid_id=objectId)
×
930
        if pub.pbn_uid_id is not None and pub.pbn_uid_id != objectId:
×
931
            SentData.objects.updated(pub, js, pbn_uid_id=pub.pbn_uid_id)
×
932

933
        if pub.pbn_uid_id != objectId:
×
934
            # Rekord dostaje nowe objectId z PBNu.
935

936
            # Czy rekord JUŻ Z PBN UID dostaje nowe PBN UID z PBNu? Powiadom użytkownika.
937
            if pub.pbn_uid_id is not None:
×
938
                if notificator is not None:
×
939
                    notificator.error(
×
940
                        f"UWAGA UWAGA UWAGA. Wg danych z PBN zmodyfikowano PBN UID tego rekordu "
941
                        f"z wartości {pub.pbn_uid_id} na {objectId}. Technicznie nie jest to błąd, "
942
                        f"ale w praktyce dobrze by było zweryfikować co się zadziało, zarówno po stronie"
943
                        f"PBNu jak i BPP. Być może operujesz na rekordzie ze zdublowanym DOI/stronie WWW."
944
                    )
945

946
                message = (
×
947
                    f"Zarejestrowano zmianę ZAPISANEGO WCZEŚNIEJ PBN UID publikacji przez PBN, \n"
948
                    f"Publikacja:\n{pub}\n\n"
949
                    f"z UIDu {pub.pbn_uid_id} na {objectId}"
950
                )
951

952
                try:
×
953
                    raise PBNUIDChangedException(message)
×
954
                except PBNUIDChangedException as e:
×
955
                    capture_exception(e)
×
956

957
                mail_admins(
×
958
                    "Zmiana PBN UID publikacji przez serwer PBN",
959
                    message,
960
                    fail_silently=True,
961
                )
962

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

967
            istniejace_rekordy = Rekord.objects.filter(pbn_uid_id=objectId)
×
968
            if pub.pbn_uid_id is None and istniejace_rekordy.exists():
×
969
                if notificator is not None:
×
970
                    notificator.error(
×
971
                        f'UWAGA UWAGA UWAGA. Wysłany rekord "{pub}" dostał w odpowiedzi z serwera PBN numer UID '
972
                        f"rekordu JUŻ ISTNIEJĄCEGO W BAZIE DANYCH BPP, a konkretnie {istniejace_rekordy.all()}. "
973
                        f"Z przyczyn oczywistych NIE MOGĘ ustawić takiego PBN UID gdyż wówczas unikalność numerów PBN "
974
                        f"UID byłaby naruszona. Zapewne też doszło do "
975
                        f"NADPISANIA danych w/wym rekordu po stronie PBNu. Powinieneś/aś wycofać zmiany w PBNie "
976
                        f"za pomocą GUI, zgłosić tą sytuację do administratora oraz zaprzestać prób wysyłki "
977
                        f"tego rekordu do wyjaśnienia. "
978
                    )
979

980
                message = (
×
981
                    f"Zarejestrowano ustawienie nowo wysłanej pracy ISTNIEJĄCEGO JUŻ W BAZIE PBN UID\n"
982
                    f"Publikacja:\n{pub}\n\n"
983
                    f"UIDu {objectId}\n"
984
                    f"Istniejąca praca/e: {istniejace_rekordy.all()}"
985
                )
986

987
                try:
×
988
                    raise PBNUIDSetToExistentException(message)
×
989
                except PBNUIDSetToExistentException as e:
×
990
                    capture_exception(e)
×
991

992
                mail_admins(
×
993
                    "Ustawienie ISTNIEJĄCEGO JUŻ W BAZIE PBN UID publikacji przez serwer PBN",
994
                    message,
995
                    fail_silently=True,
996
                )
997

998
                # NIE zapisuj takiego numeru PBN
999
                return
×
1000

1001
            pub.pbn_uid = publication
×
1002
            pub.save()
×
1003

1004
        return publication
×
1005

1006
    def eventually_coerce_to_publication(self, pub: Model | str) -> Model:
2✔
1007
        if type(pub) is str:
×
1008
            # Ciag znaków w postaci wydawnictwo_zwarte:123 pozwoli na podawanie tego
1009
            # parametru do wywołań z linii poleceń
1010
            model, pk = pub.split(":")
×
1011
            ctype = ContentType.objects.get(app_label="bpp", model=model)
×
1012
            pub = ctype.model_class().objects.get(pk=pk)
×
1013

1014
        return pub
×
1015

1016
    def upload_publication_fee(self, pub: Model):
2✔
1017
        pub = self.eventually_coerce_to_publication(pub)
×
1018
        if pub.pbn_uid_id is None:
×
1019
            raise NoPBNUIDException(
×
1020
                f"PBN UID (czyli 'numer odpowiednika w PBN') dla rekordu '{pub}' jest pusty."
1021
            )
1022

1023
        fee = OplataZaWydawnictwoPBNAdapter(pub).pbn_get_json()
×
1024
        if not fee:
×
1025
            raise NoFeeDataException(
×
1026
                f"Brak danych o opłatach za publikację {pub.pbn_uid_id}"
1027
            )
1028

1029
        return self.post_publication_fee(pub.pbn_uid_id, fee)
×
1030

1031
    def exec(self, cmd):
2✔
1032
        try:
×
1033
            fun = getattr(self, cmd[0])
×
1034
        except AttributeError as e:
×
1035
            if self._interactive:
×
1036
                print("No such command: %s" % cmd)
×
1037
                return
×
1038
            else:
1039
                raise e
×
1040

1041
        def extract_arguments(lst):
×
1042
            args = ()
×
1043
            kw = {}
×
1044
            for elem in lst:
×
1045
                if elem.find(":") >= 1:
×
1046
                    k, n = elem.split(":", 1)
×
1047
                    kw[k] = n
×
1048
                else:
1049
                    args += (elem,)
×
1050

1051
            return args, kw
×
1052

1053
        args, kw = extract_arguments(cmd[1:])
×
1054
        res = fun(*args, **kw)
×
1055

1056
        if not sys.stdout.isatty():
×
1057
            # Non-interactive mode, just output the json
1058
            import json
×
1059

1060
            print(json.dumps(res))
×
1061
        else:
1062
            if type(res) is dict:
×
1063
                pprint(res)
×
1064
            elif is_iterable(res):
×
1065
                if self._interactive and hasattr(res, "total_elements"):
×
1066
                    print(
×
1067
                        "Incoming data: no_elements=",
1068
                        res.total_elements,
1069
                        "no_pages=",
1070
                        res.total_pages,
1071
                    )
1072
                    input("Press ENTER to continue> ")
×
1073
                for elem in res:
×
1074
                    pprint(elem)
×
1075

1076
    @transaction.atomic
2✔
1077
    def download_disciplines(self):
2✔
1078
        """Zapisuje słownik dyscyplin z API PBN do lokalnej bazy"""
1079

1080
        for elem in self.get_disciplines():
×
1081
            validityDateFrom = elem.get("validityDateFrom", None)
×
1082
            validityDateTo = elem.get("validityDateTo", None)
×
1083
            uuid = elem["uuid"]
×
1084

1085
            parent_group, created = DisciplineGroup.objects.update_or_create(
×
1086
                uuid=uuid,
1087
                defaults={
1088
                    "validityDateFrom": validityDateFrom,
1089
                    "validityDateTo": validityDateTo,
1090
                },
1091
            )
1092

1093
            for discipline in elem["disciplines"]:
×
1094
                # print("XXX", discipline["uuid"])
1095
                Discipline.objects.update_or_create(
×
1096
                    parent_group=parent_group,
1097
                    uuid=discipline["uuid"],
1098
                    defaults=dict(
1099
                        code=discipline["code"],
1100
                        name=discipline["name"],
1101
                        polonCode=discipline["polonCode"],
1102
                        scientificFieldName=discipline["scientificFieldName"],
1103
                    ),
1104
                )
1105

1106
    @transaction.atomic
2✔
1107
    def sync_disciplines(self):
2✔
1108
        self.download_disciplines()
×
1109
        try:
×
1110
            cur_dg = DisciplineGroup.objects.get_current()
×
1111
        except DisciplineGroup.DoesNotExist:
×
1112
            raise ValueError(
×
1113
                "Brak aktualnego słownika dyscyplin na serwerze. Pobierz aktualny słownik "
1114
                "dyscyplin z PBN."
1115
            )
1116

1117
        from bpp.models import Dyscyplina_Naukowa
×
1118

1119
        for dyscyplina in Dyscyplina_Naukowa.objects.all():
×
1120
            wpis_tlumacza = TlumaczDyscyplin.objects.get_or_create(
×
1121
                dyscyplina_w_bpp=dyscyplina
1122
            )[0]
1123

1124
            wpis_tlumacza.pbn_2024_now = matchuj_aktualna_dyscypline_pbn(
×
1125
                dyscyplina.kod, dyscyplina.nazwa
1126
            )
1127
            # Domyślnie szuka dla lat 2018-2022
1128
            wpis_tlumacza.pbn_2017_2021 = matchuj_nieaktualna_dyscypline_pbn(
×
1129
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2018, rok_max=2022
1130
            )
1131

1132
            wpis_tlumacza.pbn_2022_2023 = matchuj_nieaktualna_dyscypline_pbn(
×
1133
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2023, rok_max=2024
1134
            )
1135

1136
            wpis_tlumacza.save()
×
1137

1138
        for discipline in cur_dg.discipline_set.all():
×
1139
            if discipline.name == "weterynaria":
×
1140
                pass
×
1141
            # Każda dyscyplina z aktualnego słownika powinna być wpisana do systemu BPP
1142
            try:
×
1143
                TlumaczDyscyplin.objects.get(pbn_2024_now=discipline)
×
1144
            except TlumaczDyscyplin.DoesNotExist:
×
1145
                try:
×
1146
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.get(
×
1147
                        kod=normalize_kod_dyscypliny(discipline.code)
1148
                    )
1149
                    TlumaczDyscyplin.objects.get_or_create(
×
1150
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1151
                    )
1152

1153
                except Dyscyplina_Naukowa.DoesNotExist:
×
1154
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.create(
×
1155
                        kod=normalize_kod_dyscypliny(discipline.code),
1156
                        nazwa=discipline.name,
1157
                    )
1158
                    TlumaczDyscyplin.objects.get_or_create(
×
1159
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1160
                    )
1161

1162
    def interactive(self):
2✔
1163
        self._interactive = True
×
1164
        while True:
×
1165
            cmd = input("cmd> ")
×
1166
            if cmd == "exit":
×
1167
                break
×
1168
            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