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

iplweb / bpp / e667ae68-7418-40f7-b1db-97651d428761

11 Aug 2025 05:17PM UTC coverage: 46.666% (+1.3%) from 45.341%
e667ae68-7418-40f7-b1db-97651d428761

push

circleci

mpasternak
Merge branch 'release/v202508.1188'

11 of 116 new or added lines in 8 files covered. (9.48%)

1186 existing lines in 91 files now uncovered.

17772 of 38083 relevant lines covered (46.67%)

1.19 hits per line

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

23.01
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

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

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

70

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

77

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

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

87
        self.access_token = user_token
×
88

89

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

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

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

108
    def count(self):
3✔
109
        return self.total_elements
×
110

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

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

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

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

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

132

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

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

157
            raise AuthenticationResponseError(response.content)
×
158

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

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

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

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

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

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

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

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

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

191

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

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

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

206
        retries = 0
×
207
        MAX_RETRIES = 15
×
208

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

350
            raise e
×
351

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

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

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

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

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

391
        method_function = getattr(self, method)
×
392

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

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

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

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

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

431

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

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

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

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

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

448

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

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

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

460

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

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

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

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

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

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

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

490

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

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

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

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

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

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

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

545
            raise CannotDeleteStatementsException(e.content)
×
546

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

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

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

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

567

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

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

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

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

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

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

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

590

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

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

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

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

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

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

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

613

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

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

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

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

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

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

635

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

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

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

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

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

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

660

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

665

666
def rename_dict_key(data, old_key, new_key):
3✔
667
    """
668
    Recursively rename a dictionary key in a dictionary and all nested dictionaries.
669

670
    Args:
671
        data: Dictionary or any data structure that may contain dictionaries
672
        old_key: The key to be renamed
673
        new_key: The new key name
674

675
    Returns:
676
        The modified data structure with renamed keys
677
    """
NEW
678
    if isinstance(data, dict):
×
679
        # Create a new dictionary with renamed keys
NEW
680
        new_dict = {}
×
NEW
681
        for key, value in data.items():
×
682
            # Rename the key if it matches
NEW
683
            new_key_name = new_key if key == old_key else key
×
684
            # Recursively process the value
NEW
685
            new_dict[new_key_name] = rename_dict_key(value, old_key, new_key)
×
NEW
686
        return new_dict
×
NEW
687
    elif isinstance(data, list):
×
688
        # Process each item in the list
NEW
689
        return [rename_dict_key(item, old_key, new_key) for item in data]
×
690
    else:
691
        # Return the value unchanged if it's not a dict or list
NEW
692
        return data
×
693

694

695
class PBNClient(
3✔
696
    ConferencesMixin,
697
    DictionariesMixin,
698
    InstitutionsMixin,
699
    InstitutionsProfileMixin,
700
    JournalsMixin,
701
    PersonMixin,
702
    PublicationsMixin,
703
    PublishersMixin,
704
    SearchMixin,
705
):
706
    _interactive = False
3✔
707

708
    def __init__(self, transport: RequestsTransport):
3✔
709
        self.transport = transport
1✔
710

711
    def post_publication(self, json):
3✔
712
        return self.transport.post(PBN_POST_PUBLICATIONS_URL, body=json)
×
713

714
    def post_publication_no_statements(self, json):
3✔
715
        """
716
        Ta funkcja służy do wysyłania publikacji BEZ oświadczeń.
717

718
        Bierzemy słownik JSON z publikacji-z-oświadczeniami i przetwarzamy go.
719

720
        :param json:
721
        :return:
722
        """
723

724
        # PBN zmienił givenNames na firstName
NEW
725
        for elem in json.get("authors", []):
×
NEW
726
            elem["firstName"] = elem.pop("givenNames")
×
727

728
        # PBN życzy abstrakty w root
NEW
729
        abstracts = json.pop("languageData", {}).get("abstracts", [])
×
NEW
730
        if abstracts:
×
NEW
731
            json["abstracts"] = abstracts
×
732

733
        # PBN nie życzy opłat
NEW
734
        json.pop("fee", None)
×
735

736
        # PBN zmienił nazwę mniswId na ministryId
NEW
737
        json = rename_dict_key(json, "mniswId", "ministryId")
×
738

739
        # Można próbować
NEW
740
        return self.transport.post(PBN_POST_PUBLICATION_NO_STATEMENTS_URL, body=[json])
×
741

742
    def post_publication_fee(self, publicationId, json):
3✔
743
        return self.transport.post(
×
744
            PBN_POST_PUBLICATION_FEE_URL.format(id=publicationId), body=json
745
        )
746

747
    def get_publication_fee(self, publicationId):
3✔
748
        res = self.transport.post_pages(
×
749
            "/api/v1/institutionProfile/publications/search/fees",
750
            body={"publicationIds": [str(publicationId)]},
751
        )
752
        if not res.count():
×
753
            return
×
754
        elif res.count() == 1:
×
755
            return list(res)[0]
×
756
        else:
757
            raise NotImplementedError("count > 1")
×
758

759
    def upload_publication(
3✔
760
        self, rec, force_upload=False, export_pk_zero=None, always_affiliate_to_uid=None
761
    ):
762
        """
763
        Ta funkcja wysyła dane publikacji na serwer, w zależności od obecności oświadczeń
764
        w JSONie (klucz: "statements") używa albo api /v1/ do wysyłki publikacji "ze wszystkim",
765
        albo korzysta z api /v1/ repozytoryjnego.
766

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

UNCOV
771
        js = WydawnictwoPBNAdapter(
×
772
            rec,
773
            export_pk_zero=export_pk_zero,
774
            always_affiliate_to_uid=always_affiliate_to_uid,
775
        ).pbn_get_json()
776

777
        if not force_upload:
×
778
            needed = SentData.objects.check_if_needed(rec, js)
×
779
            if not needed:
×
780
                raise SameDataUploadedRecently(
×
781
                    SentData.objects.get_for_rec(rec).last_updated_on
782
                )
783

784
        try:
×
NEW
785
            if "statements" in js:
×
NEW
786
                ret = self.post_publication(js)
×
NEW
787
                objectId = ret.get("objectId", None)
×
NEW
788
                bez_oswiadczen = False
×
789

790
            else:
NEW
791
                ret = self.post_publication_no_statements(js)
×
792

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

807
        except Exception as e:
×
808
            SentData.objects.updated(rec, js, uploaded_okay=False, exception=str(e))
×
809
            raise e
×
810

NEW
811
        return objectId, ret, js, bez_oswiadczen
×
812

813
    def download_publication(self, doi=None, objectId=None):
3✔
814
        from .integrator import zapisz_mongodb
×
815
        from .models import Publication
×
816

817
        assert doi or objectId
×
818

819
        if doi:
×
820
            data = self.get_publication_by_doi(doi)
×
821
        elif objectId:
×
822
            data = self.get_publication_by_id(objectId)
×
823

824
        return zapisz_mongodb(data, Publication)
×
825

826
    @transaction.atomic
3✔
827
    def download_statements_of_publication(self, pub):
3✔
828
        from pbn_api.models import OswiadczenieInstytucji
×
829
        from .integrator import pobierz_mongodb, zapisz_oswiadczenie_instytucji
×
830

831
        OswiadczenieInstytucji.objects.filter(publicationId_id=pub.pk).delete()
×
832

833
        pobierz_mongodb(
×
834
            self.get_institution_statements_of_single_publication(pub.pk, 5120),
835
            None,
836
            fun=zapisz_oswiadczenie_instytucji,
837
            client=self,
838
            disable_progress_bar=True,
839
        )
840

841
    def pobierz_publikacje_instytucji_v2(self, objectId):
3✔
842
        from pbn_api.integrator import zapisz_publikacje_instytucji_v2
×
843

844
        elem = list(self.get_institution_publication_v2(objectId=objectId))
×
845
        if not elem:
×
846
            raise PublikacjaInstytucjiV2NieZnalezionaException(objectId)
×
847

848
        if len(elem) != 1:
×
849
            raise ZnalezionoWielePublikacjiInstytucjiV2Exception(objectId)
×
850

851
        return zapisz_publikacje_instytucji_v2(self, elem[0])
×
852

853
    def sync_publication(
3✔
854
        self,
855
        pub,
856
        notificator=None,
857
        force_upload=False,
858
        delete_statements_before_upload=False,
859
        export_pk_zero=None,
860
        always_affiliate_to_uid=None,
861
    ):
862
        """
863
        @param delete_statements_before_upload: gdy True, kasuj oświadczenia publikacji przed wysłaniem (jeżeli posiada
864
        PBN UID)
865
        """
866

867
        # if not pub.doi:
868
        #     raise WillNotExportError("Ustaw DOI dla publikacji")
869

870
        pub = self.eventually_coerce_to_publication(pub)
×
871

872
        #
873
        if (
×
874
            delete_statements_before_upload
875
            and hasattr(pub, "pbn_uid_id")
876
            and pub.pbn_uid_id is not None
877
        ):
878
            try:
×
879
                self.delete_all_publication_statements(pub.pbn_uid_id)
×
880

881
                # Jeżeli zostały skasowane dane, to wymuś wysłanie rekordu, niezależnie
882
                # od stanu tabeli SentData
883
                force_upload = True
×
884
            except HttpException as e:
×
885
                NIE_ISTNIEJA = "Nie istnieją oświadczenia dla publikacji"
×
886

887
                ignored_exception = False
×
888

889
                if e.status_code == 400:
×
890
                    if e.json:
×
891
                        try:
×
892
                            try:
×
893
                                msg = e.json["details"]["publicationId"]
×
894
                            except KeyError:
×
895
                                msg = e.json["details"][
×
896
                                    f"publicationId.{pub.pbn_uid_id}"
897
                                ]
898
                            if NIE_ISTNIEJA in msg:
×
899
                                ignored_exception = True
×
900
                        except (TypeError, KeyError):
×
901
                            if NIE_ISTNIEJA in e.content:
×
902
                                ignored_exception = True
×
903

904
                    else:
905
                        if NIE_ISTNIEJA in e.content:
×
906
                            ignored_exception = True
×
907

908
                if not ignored_exception:
×
909
                    raise e
×
910

911
        # Wgraj dane do PBN
NEW
912
        objectId, ret, js, bez_oswiadczen = self.upload_publication(
×
913
            pub,
914
            force_upload=force_upload,
915
            export_pk_zero=export_pk_zero,
916
            always_affiliate_to_uid=always_affiliate_to_uid,
917
        )
918

NEW
919
        if bez_oswiadczen:
×
NEW
920
            if notificator is not None:
×
NEW
921
                notificator.info(
×
922
                    "Rekord nie posiada oświadczeń - wysłano do repozytorium. "
923
                )
924

NEW
925
        if not objectId:
×
UNCOV
926
            msg = (
×
927
                f"UWAGA. Serwer PBN nie odpowiedział prawidłowym PBN UID dla"
928
                f" wysyłanego rekordu. Zgłoś sytuację do administratora serwisu. "
929
                f"{ret=}, {js=}, {pub=}"
930
            )
931
            if notificator is not None:
×
932
                notificator.error(msg)
×
933

934
            try:
×
935
                raise NoPBNUIDException(msg)
×
936
            except NoPBNUIDException as e:
×
937
                capture_exception(e)
×
938

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

NEW
941
            return
×
942

943
        # Pobierz zwrotnie dane z PBN
NEW
944
        publication = self.download_publication(objectId=objectId)
×
945

NEW
946
        if not bez_oswiadczen:
×
NEW
947
            self.download_statements_of_publication(publication)
×
NEW
948
            try:
×
NEW
949
                self.pobierz_publikacje_instytucji_v2(objectId=objectId)
×
NEW
950
            except PublikacjaInstytucjiV2NieZnalezionaException:
×
NEW
951
                notificator.warning(
×
952
                    "Nie znaleziono oświadczeń dla publikacji po stronie PBN w wersji V2 API."
953
                )
954

955
        # Utwórz obiekt zapisanych danych. Dopiero w tym miejscu, bo jeżeli zostanie
956
        # utworzony nowy rekord po stronie PBN, to pbn_uid_id musi wskazywać na
957
        # bazę w tabeli Publication, która została chwile temu pobrana...
NEW
958
        SentData.objects.updated(pub, js, pbn_uid_id=objectId)
×
NEW
959
        if pub.pbn_uid_id is not None and pub.pbn_uid_id != objectId:
×
UNCOV
960
            SentData.objects.updated(pub, js, pbn_uid_id=pub.pbn_uid_id)
×
961

NEW
962
        if pub.pbn_uid_id != objectId:
×
963
            # Rekord dostaje nowe objectId z PBNu.
964

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

975
                message = (
×
976
                    f"Zarejestrowano zmianę ZAPISANEGO WCZEŚNIEJ PBN UID publikacji przez PBN, \n"
977
                    f"Publikacja:\n{pub}\n\n"
978
                    f"z UIDu {pub.pbn_uid_id} na {objectId}"
979
                )
980

981
                try:
×
982
                    raise PBNUIDChangedException(message)
×
983
                except PBNUIDChangedException as e:
×
984
                    capture_exception(e)
×
985

986
                mail_admins(
×
987
                    "Zmiana PBN UID publikacji przez serwer PBN",
988
                    message,
989
                    fail_silently=True,
990
                )
991

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

NEW
996
            istniejace_rekordy = Rekord.objects.filter(pbn_uid_id=objectId)
×
997
            if pub.pbn_uid_id is None and istniejace_rekordy.exists():
×
998
                if notificator is not None:
×
999
                    notificator.error(
×
1000
                        f'UWAGA UWAGA UWAGA. Wysłany rekord "{pub}" dostał w odpowiedzi z serwera PBN numer UID '
1001
                        f"rekordu JUŻ ISTNIEJĄCEGO W BAZIE DANYCH BPP, a konkretnie {istniejace_rekordy.all()}. "
1002
                        f"Z przyczyn oczywistych NIE MOGĘ ustawić takiego PBN UID gdyż wówczas unikalność numerów PBN "
1003
                        f"UID byłaby naruszona. Zapewne też doszło do "
1004
                        f"NADPISANIA danych w/wym rekordu po stronie PBNu. Powinieneś/aś wycofać zmiany w PBNie "
1005
                        f"za pomocą GUI, zgłosić tą sytuację do administratora oraz zaprzestać prób wysyłki "
1006
                        f"tego rekordu do wyjaśnienia. "
1007
                    )
1008

1009
                message = (
×
1010
                    f"Zarejestrowano ustawienie nowo wysłanej pracy ISTNIEJĄCEGO JUŻ W BAZIE PBN UID\n"
1011
                    f"Publikacja:\n{pub}\n\n"
1012
                    f"UIDu {objectId}\n"
1013
                    f"Istniejąca praca/e: {istniejace_rekordy.all()}"
1014
                )
1015

1016
                try:
×
1017
                    raise PBNUIDSetToExistentException(message)
×
1018
                except PBNUIDSetToExistentException as e:
×
1019
                    capture_exception(e)
×
1020

1021
                mail_admins(
×
1022
                    "Ustawienie ISTNIEJĄCEGO JUŻ W BAZIE PBN UID publikacji przez serwer PBN",
1023
                    message,
1024
                    fail_silently=True,
1025
                )
1026

1027
                # NIE zapisuj takiego numeru PBN
1028
                return
×
1029

1030
            pub.pbn_uid = publication
×
1031
            pub.save()
×
1032

NEW
1033
        return publication
×
1034

1035
    def eventually_coerce_to_publication(self, pub: Model | str) -> Model:
3✔
1036
        if type(pub) is str:
×
1037
            # Ciag znaków w postaci wydawnictwo_zwarte:123 pozwoli na podawanie tego
1038
            # parametru do wywołań z linii poleceń
1039
            model, pk = pub.split(":")
×
1040
            ctype = ContentType.objects.get(app_label="bpp", model=model)
×
1041
            pub = ctype.model_class().objects.get(pk=pk)
×
1042

1043
        return pub
×
1044

1045
    def upload_publication_fee(self, pub: Model):
3✔
1046
        pub = self.eventually_coerce_to_publication(pub)
×
1047
        if pub.pbn_uid_id is None:
×
1048
            raise NoPBNUIDException(
×
1049
                f"PBN UID (czyli 'numer odpowiednika w PBN') dla rekordu '{pub}' jest pusty."
1050
            )
1051

1052
        fee = OplataZaWydawnictwoPBNAdapter(pub).pbn_get_json()
×
1053
        if not fee:
×
1054
            raise NoFeeDataException(
×
1055
                f"Brak danych o opłatach za publikację {pub.pbn_uid_id}"
1056
            )
1057

1058
        return self.post_publication_fee(pub.pbn_uid_id, fee)
×
1059

1060
    def exec(self, cmd):
3✔
1061
        try:
×
1062
            fun = getattr(self, cmd[0])
×
1063
        except AttributeError as e:
×
1064
            if self._interactive:
×
1065
                print("No such command: %s" % cmd)
×
1066
                return
×
1067
            else:
1068
                raise e
×
1069

1070
        def extract_arguments(lst):
×
1071
            args = ()
×
1072
            kw = {}
×
1073
            for elem in lst:
×
1074
                if elem.find(":") >= 1:
×
1075
                    k, n = elem.split(":", 1)
×
1076
                    kw[k] = n
×
1077
                else:
1078
                    args += (elem,)
×
1079

1080
            return args, kw
×
1081

1082
        args, kw = extract_arguments(cmd[1:])
×
1083
        res = fun(*args, **kw)
×
1084

1085
        if not sys.stdout.isatty():
×
1086
            # Non-interactive mode, just output the json
1087
            import json
×
1088

1089
            print(json.dumps(res))
×
1090
        else:
1091
            if type(res) is dict:
×
1092
                pprint(res)
×
1093
            elif is_iterable(res):
×
1094
                if self._interactive and hasattr(res, "total_elements"):
×
1095
                    print(
×
1096
                        "Incoming data: no_elements=",
1097
                        res.total_elements,
1098
                        "no_pages=",
1099
                        res.total_pages,
1100
                    )
1101
                    input("Press ENTER to continue> ")
×
1102
                for elem in res:
×
1103
                    pprint(elem)
×
1104

1105
    @transaction.atomic
3✔
1106
    def download_disciplines(self):
3✔
1107
        """Zapisuje słownik dyscyplin z API PBN do lokalnej bazy"""
1108

1109
        for elem in self.get_disciplines():
×
1110
            validityDateFrom = elem.get("validityDateFrom", None)
×
1111
            validityDateTo = elem.get("validityDateTo", None)
×
1112
            uuid = elem["uuid"]
×
1113

1114
            parent_group, created = DisciplineGroup.objects.update_or_create(
×
1115
                uuid=uuid,
1116
                defaults={
1117
                    "validityDateFrom": validityDateFrom,
1118
                    "validityDateTo": validityDateTo,
1119
                },
1120
            )
1121

1122
            for discipline in elem["disciplines"]:
×
1123
                # print("XXX", discipline["uuid"])
1124
                Discipline.objects.update_or_create(
×
1125
                    parent_group=parent_group,
1126
                    uuid=discipline["uuid"],
1127
                    defaults=dict(
1128
                        code=discipline["code"],
1129
                        name=discipline["name"],
1130
                        polonCode=discipline["polonCode"],
1131
                        scientificFieldName=discipline["scientificFieldName"],
1132
                    ),
1133
                )
1134

1135
    @transaction.atomic
3✔
1136
    def sync_disciplines(self):
3✔
1137
        self.download_disciplines()
×
1138
        try:
×
1139
            cur_dg = DisciplineGroup.objects.get_current()
×
1140
        except DisciplineGroup.DoesNotExist:
×
1141
            raise ValueError(
×
1142
                "Brak aktualnego słownika dyscyplin na serwerze. Pobierz aktualny słownik "
1143
                "dyscyplin z PBN."
1144
            )
1145

1146
        from bpp.models import Dyscyplina_Naukowa
×
1147

1148
        for dyscyplina in Dyscyplina_Naukowa.objects.all():
×
1149
            wpis_tlumacza = TlumaczDyscyplin.objects.get_or_create(
×
1150
                dyscyplina_w_bpp=dyscyplina
1151
            )[0]
1152

1153
            wpis_tlumacza.pbn_2024_now = matchuj_aktualna_dyscypline_pbn(
×
1154
                dyscyplina.kod, dyscyplina.nazwa
1155
            )
1156
            # Domyślnie szuka dla lat 2018-2022
1157
            wpis_tlumacza.pbn_2017_2021 = matchuj_nieaktualna_dyscypline_pbn(
×
1158
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2018, rok_max=2022
1159
            )
1160

1161
            wpis_tlumacza.pbn_2022_2023 = matchuj_nieaktualna_dyscypline_pbn(
×
1162
                dyscyplina.kod, dyscyplina.nazwa, rok_min=2023, rok_max=2024
1163
            )
1164

1165
            wpis_tlumacza.save()
×
1166

1167
        for discipline in cur_dg.discipline_set.all():
×
1168
            if discipline.name == "weterynaria":
×
1169
                pass
×
1170
            # Każda dyscyplina z aktualnego słownika powinna być wpisana do systemu BPP
1171
            try:
×
1172
                TlumaczDyscyplin.objects.get(pbn_2024_now=discipline)
×
1173
            except TlumaczDyscyplin.DoesNotExist:
×
1174
                try:
×
1175
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.get(
×
1176
                        kod=normalize_kod_dyscypliny(discipline.code)
1177
                    )
1178
                    TlumaczDyscyplin.objects.get_or_create(
×
1179
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1180
                    )
1181

1182
                except Dyscyplina_Naukowa.DoesNotExist:
×
1183
                    dyscyplina_w_bpp = Dyscyplina_Naukowa.objects.create(
×
1184
                        kod=normalize_kod_dyscypliny(discipline.code),
1185
                        nazwa=discipline.name,
1186
                    )
1187
                    TlumaczDyscyplin.objects.get_or_create(
×
1188
                        dyscyplina_w_bpp=dyscyplina_w_bpp
1189
                    )
1190

1191
    def interactive(self):
3✔
1192
        self._interactive = True
×
1193
        while True:
×
1194
            cmd = input("cmd> ")
×
1195
            if cmd == "exit":
×
1196
                break
×
1197
            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