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

iplweb / bpp / 7d423bde-8caf-4c8b-8bf5-12ad3f831949

22 Aug 2025 01:06PM UTC coverage: 45.781% (+0.06%) from 45.719%
7d423bde-8caf-4c8b-8bf5-12ad3f831949

push

circleci

mpasternak
Merge branch 'release/v202508.1201'

11 of 18 new or added lines in 3 files covered. (61.11%)

1245 existing lines in 104 files now uncovered.

17481 of 38184 relevant lines covered (45.78%)

1.18 hits per line

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

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

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

20
from import_common.core import (
3✔
21
    matchuj_aktualna_dyscypline_pbn,
22
    matchuj_nieaktualna_dyscypline_pbn,
23
)
24
from import_common.normalization import normalize_kod_dyscypliny
3✔
25
from pbn_api.adapters.wydawnictwo import (
3✔
26
    OplataZaWydawnictwoPBNAdapter,
27
    WydawnictwoPBNAdapter,
28
)
29
from pbn_api.const import (
3✔
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 (
3✔
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
3✔
63
from pbn_api.models.discipline import Discipline, DisciplineGroup
3✔
64
from pbn_api.models.sentdata import SentData
3✔
65
from pbn_api.utils import rename_dict_key
3✔
66

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

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

71

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

78

79
class PBNClientTransport:
3✔
80
    def __init__(self, app_id, app_token, base_url, user_token=None):
3✔
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:
3✔
92
    def __init__(self, transport, res, url, headers, body=None, method="get"):
3✔
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):
3✔
110
        return self.total_elements
×
111

112
    def fetch_page(self, current_page):
3✔
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):
3✔
130
        for n in range(0, self.total_pages):
×
131
            yield from self.fetch_page(n)
×
132

133

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

139
    @classmethod
3✔
140
    def get_user_token(klass, base_url, app_id, app_token, one_time_token):
3✔
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):
3✔
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):
3✔
194
    def get(self, url, headers=None, fail_on_auth_missing=False):
3✔
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):
3✔
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(
3✔
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):
3✔
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):
3✔
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):
3✔
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:
3✔
434
    def get_conferences(self, *args, **kw):
3✔
435
        return self.transport.get_pages("/api/v1/conferences/page", *args, **kw)
×
436

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

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

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

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

449

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

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

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

461

462
class InstitutionsMixin:
3✔
463
    def get_institutions(self, status="ACTIVE", *args, **kw):
3✔
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):
3✔
469
        return self.transport.get(f"/api/v1/institutions/{id}")
×
470

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

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

477
    def get_institutions_polon(self, includeAllVersions="true", *args, **kw):
3✔
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):
3✔
486
        return self.transport.get(f"/api/v1/institutions/polon/uid/{uid}")
×
487

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

491

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

498
    def get_institution_publications_v2(
3✔
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):
3✔
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(
3✔
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(
3✔
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):
3✔
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
            try:
×
536
                ret_json = json.loads(e.content)
×
537
            except BaseException:
×
538
                raise e
×
539

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

546
            raise CannotDeleteStatementsException(e.content)
×
547

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

554
    def post_discipline_statements(self, statements_data):
3✔
555
        """
556
        Send discipline statements to PBN API.
557

558
        Args:
559
            statements_data (list): List of statement dictionaries containing discipline information
560

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

568

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

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

576
    def get_journals(self, *args, **kw):
3✔
577
        return self.transport.get_pages("/api/v1/journals/page", *args, **kw)
×
578

579
    def get_journals_v2(self, *args, **kw):
3✔
580
        return self.transport.get_pages("/api/v2/journals/page", *args, **kw)
×
581

582
    def get_journal_by_version(self, version):
3✔
583
        return self.transport.get(f"/api/v1/journals/version/{version}")
×
584

585
    def get_journal_by_id(self, id):
3✔
586
        return self.transport.get(PBN_GET_JOURNAL_BY_ID.format(id=id))
×
587

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

591

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

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

599
    def get_person_by_orcid(self, orcid):
3✔
600
        return self.transport.get(f"/api/v1/person/orcid/{orcid}")
×
601

602
    def get_people(self, *args, **kw):
3✔
603
        return self.transport.get_pages("/api/v1/person/page", *args, **kw)
×
604

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

608
    def get_person_by_version(self, version):
3✔
609
        return self.transport.get(f"/api/v1/person/version/{version}")
×
610

611
    def get_person_by_id(self, id):
3✔
612
        return self.transport.get(f"/api/v1/person/{id}")
×
613

614

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

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

624
    def get_publishers(self, *args, **kw):
3✔
625
        return self.transport.get_pages("/api/v1/publishers/page", *args, **kw)
×
626

627
    def get_publisher_by_version(self, version):
3✔
628
        return self.transport.get(f"/api/v1/publishers/version/{version}")
×
629

630
    def get_publisher_by_id(self, id):
3✔
631
        return self.transport.get(f"/api/v1/publishers/{id}")
×
632

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

636

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

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

649
    def get_publication_by_id(self, id):
3✔
650
        return self.transport.get(PBN_GET_PUBLICATION_BY_ID_URL.format(id=id))
×
651

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

655
    def get_publications(self, **kw):
3✔
656
        return self.transport.get_pages("/api/v1/publications/page", **kw)
×
657

658
    def get_publication_by_version(self, version):
3✔
659
        return self.transport.get(f"/api/v1/publications/version/{version}")
×
660

661

662
class SearchMixin:
3✔
663
    def search_publications(self, *args, **kw):
3✔
664
        return self.transport.post_pages(PBN_SEARCH_PUBLICATIONS_URL, body=kw)
×
665

666

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

680
    def __init__(self, transport: RequestsTransport):
3✔
UNCOV
681
        self.transport = transport
1✔
682

683
    def post_publication(self, json):
3✔
684
        return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json)
×
685

686
    def post_publication_no_statements(self, json):
3✔
687
        """
688
        Ta funkcja służy do wysyłania publikacji BEZ oświadczeń.
689

690
        Bierzemy słownik JSON z publikacji-z-oświadczeniami i przetwarzamy go.
691

692
        :param json:
693
        :return:
694
        """
695

696
        # PBN zmienił givenNames na firstName
697
        for elem in json.get("authors", []):
×
698
            elem["firstName"] = elem.pop("givenNames")
×
699

700
        # PBN życzy abstrakty w root
701
        abstracts = json.pop("languageData", {}).get("abstracts", [])
×
702
        if abstracts:
×
703
            json["abstracts"] = abstracts
×
704

705
        # PBN nie życzy opłat
706
        json.pop("fee", None)
×
707

708
        # PBN zmienił nazwę mniswId na ministryId
709
        json = rename_dict_key(json, "mniswId", "ministryId")
×
710

711
        # Można próbować
712
        return self.transport.post(PBN_POST_PUBLICATION_NO_STATEMENTS_URL, body=[json])
×
713

714
    def post_publication_fee(self, publicationId, json):
3✔
715
        return self.transport.post(
×
716
            PBN_POST_PUBLICATION_FEE_URL.format(id=publicationId), body=json
717
        )
718

719
    def get_publication_fee(self, publicationId):
3✔
720
        res = self.transport.post_pages(
×
721
            "/api/v1/institutionProfile/publications/search/fees",
722
            body={"publicationIds": [str(publicationId)]},
723
        )
724
        if not res.count():
×
725
            return
×
726
        elif res.count() == 1:
×
727
            return list(res)[0]
×
728
        else:
729
            raise NotImplementedError("count > 1")
×
730

731
    def upload_publication(
3✔
732
        self, rec, force_upload=False, export_pk_zero=None, always_affiliate_to_uid=None
733
    ):
734
        """
735
        Ta funkcja wysyła dane publikacji na serwer, w zależności od obecności oświadczeń
736
        w JSONie (klucz: "statements") używa albo api /v1/ do wysyłki publikacji "ze wszystkim",
737
        albo korzysta z api /v1/ repozytoryjnego.
738

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

743
        js = WydawnictwoPBNAdapter(
×
744
            rec,
745
            export_pk_zero=export_pk_zero,
746
            always_affiliate_to_uid=always_affiliate_to_uid,
747
        ).pbn_get_json()
748

749
        if not force_upload:
×
750
            needed = SentData.objects.check_if_needed(rec, js)
×
751
            if not needed:
×
752
                raise SameDataUploadedRecently(
×
753
                    SentData.objects.get_for_rec(rec).last_updated_on
754
                )
755

756
        try:
×
757
            if "statements" in js:
×
758
                ret = self.post_publication(js)
×
759
                objectId = ret.get("objectId", None)
×
760
                bez_oswiadczen = False
×
761

762
            else:
763
                ret = self.post_publication_no_statements(js)
×
764

765
                if len(ret) == 1:
×
766
                    try:
×
767
                        objectId = ret[0].get("id", None)
×
768
                    except KeyError:
×
769
                        raise Exception(
×
770
                            f"Serwer zwrócił nieoczekiwaną odpowiedź. {ret=}"
771
                        )
772
                    bez_oswiadczen = True
×
773
                else:
774
                    raise Exception(
×
775
                        "Lista zwróconych obiektów przy wysyłce pracy bez oświadczeń różna od jednego. "
776
                        "Sytuacja nieobsługiwana, proszę o kontakt z autorem programu. "
777
                    )
778

779
        except Exception as e:
×
780
            SentData.objects.updated(rec, js, uploaded_okay=False, exception=str(e))
×
781
            raise e
×
782

783
        return objectId, ret, js, bez_oswiadczen
×
784

785
    def download_publication(self, doi=None, objectId=None):
3✔
786
        from .integrator import zapisz_mongodb
×
787
        from .models import Publication
×
788

789
        assert doi or objectId
×
790

791
        if doi:
×
792
            data = self.get_publication_by_doi(doi)
×
793
        elif objectId:
×
794
            data = self.get_publication_by_id(objectId)
×
795

796
        return zapisz_mongodb(data, Publication)
×
797

798
    @transaction.atomic
3✔
799
    def download_statements_of_publication(self, pub):
3✔
800
        from pbn_api.models import OswiadczenieInstytucji
×
801
        from .integrator import pobierz_mongodb, zapisz_oswiadczenie_instytucji
×
802

803
        OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk).delete()
×
804

805
        pobierz_mongodb(
×
806
            self.get_institution_statements_of_single_publication(pub.pk, 5120),
807
            None,
808
            fun=zapisz_oswiadczenie_instytucji,
809
            client=self,
810
            disable_progress_bar=True,
811
        )
812

813
    def pobierz_publikacje_instytucji_v2(self, objectId):
3✔
814
        from pbn_api.integrator import zapisz_publikacje_instytucji_v2
×
815

816
        elem = list(self.get_institution_publication_v2(objectId=objectId))
×
817
        if not elem:
×
818
            raise PublikacjaInstytucjiV2NieZnalezionaException(objectId)
×
819

820
        if len(elem) != 1:
×
821
            raise ZnalezionoWielePublikacjiInstytucjiV2Exception(objectId)
×
822

823
        return zapisz_publikacje_instytucji_v2(self, elem[0])
×
824

825
    def sync_publication(
3✔
826
        self,
827
        pub,
828
        notificator=None,
829
        force_upload=False,
830
        delete_statements_before_upload=False,
831
        export_pk_zero=None,
832
        always_affiliate_to_uid=None,
833
    ):
834
        """
835
        @param delete_statements_before_upload: gdy True, kasuj oświadczenia publikacji przed wysłaniem (jeżeli posiada
836
        PBN UID)
837
        """
838

839
        # if not pub.doi:
840
        #     raise WillNotExportError("Ustaw DOI dla publikacji")
841

842
        pub = self.eventually_coerce_to_publication(pub)
×
843

844
        #
845
        if (
×
846
            delete_statements_before_upload
847
            and hasattr(pub, "pbn_uid_id")
848
            and pub.pbn_uid_id is not None
849
        ):
850
            try:
×
851
                self.delete_all_publication_statements(pub.pbn_uid_id)
×
852

853
                # Jeżeli zostały skasowane dane, to wymuś wysłanie rekordu, niezależnie
854
                # od stanu tabeli SentData
855
                force_upload = True
×
856
            except HttpException as e:
×
857
                NIE_ISTNIEJA = "Nie istnieją oświadczenia dla publikacji"
×
858

859
                ignored_exception = False
×
860

861
                if e.status_code == 400:
×
862
                    if e.json:
×
863
                        try:
×
864
                            try:
×
865
                                msg = e.json["details"]["publicationId"]
×
866
                            except KeyError:
×
867
                                msg = e.json["details"][
×
868
                                    f"publicationId.{pub.pbn_uid_id}"
869
                                ]
870
                            if NIE_ISTNIEJA in msg:
×
871
                                ignored_exception = True
×
872
                        except (TypeError, KeyError):
×
873
                            if NIE_ISTNIEJA in e.content:
×
874
                                ignored_exception = True
×
875

876
                    else:
877
                        if NIE_ISTNIEJA in e.content:
×
878
                            ignored_exception = True
×
879

880
                if not ignored_exception:
×
881
                    raise e
×
882

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

891
        if bez_oswiadczen:
×
892
            if notificator is not None:
×
893
                notificator.info(
×
894
                    "Rekord nie posiada oświadczeń - wysłano do repozytorium. "
895
                )
896

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

906
            try:
×
907
                raise NoPBNUIDException(msg)
×
908
            except NoPBNUIDException as e:
×
909
                capture_exception(e)
×
910

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

913
            return
×
914

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

918
        if not bez_oswiadczen:
×
919
            self.download_statements_of_publication(publication)
×
920
            try:
×
921
                self.pobierz_publikacje_instytucji_v2(objectId=objectId)
×
922
            except PublikacjaInstytucjiV2NieZnalezionaException:
×
923
                notificator.warning(
×
924
                    "Nie znaleziono oświadczeń dla publikacji po stronie PBN w wersji V2 API."
925
                )
926

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

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

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

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

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

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

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

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

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

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

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

999
                # NIE zapisuj takiego numeru PBN
1000
                return
×
1001

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

1005
        return publication
×
1006

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

1015
        return pub
×
1016

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

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

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

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

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

1052
            return args, kw
×
1053

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

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

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

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

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

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

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

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

1118
        from bpp.models import Dyscyplina_Naukowa
×
1119

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

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

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

1137
            wpis_tlumacza.save()
×
1138

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

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

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