pbn-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pbn_client/__init__.py +53 -0
- pbn_client/auth.py +96 -0
- pbn_client/client.py +113 -0
- pbn_client/conf/__init__.py +0 -0
- pbn_client/conf/settings.py +46 -0
- pbn_client/const.py +33 -0
- pbn_client/dict_utils.py +48 -0
- pbn_client/exceptions.py +141 -0
- pbn_client/mixins/__init__.py +22 -0
- pbn_client/mixins/conferences.py +20 -0
- pbn_client/mixins/dictionaries.py +16 -0
- pbn_client/mixins/institutions.py +156 -0
- pbn_client/mixins/journals.py +28 -0
- pbn_client/mixins/person.py +26 -0
- pbn_client/mixins/publications.py +32 -0
- pbn_client/mixins/publishers.py +25 -0
- pbn_client/mixins/search.py +10 -0
- pbn_client/pagination.py +44 -0
- pbn_client/reporting.py +136 -0
- pbn_client/statements.py +388 -0
- pbn_client/transport.py +377 -0
- pbn_client/utils.py +23 -0
- pbn_client-0.1.0.dist-info/METADATA +108 -0
- pbn_client-0.1.0.dist-info/RECORD +26 -0
- pbn_client-0.1.0.dist-info/WHEEL +4 -0
- pbn_client-0.1.0.dist-info/licenses/LICENSE +21 -0
pbn_client/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Klient API PBN — czysta, niezależna od frameworka warstwa protokołu.
|
|
2
|
+
|
|
3
|
+
Pakiet operuje wyłącznie na pojęciach PBN: tokeny, URL-e, PBN UID-y, JSON-y
|
|
4
|
+
i flagi bool. Nie zna modelu domenowego żadnej aplikacji-hosta, więc może
|
|
5
|
+
być używany przez dowolny projekt integrujący się z PBN.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .auth import OAuthMixin
|
|
9
|
+
from .client import PBNClient
|
|
10
|
+
from .mixins import (
|
|
11
|
+
ConferencesMixin,
|
|
12
|
+
DictionariesMixin,
|
|
13
|
+
InstitutionsMixin,
|
|
14
|
+
InstitutionsProfileMixin,
|
|
15
|
+
JournalsMixin,
|
|
16
|
+
PersonMixin,
|
|
17
|
+
PublicationsMixin,
|
|
18
|
+
PublishersMixin,
|
|
19
|
+
SearchMixin,
|
|
20
|
+
)
|
|
21
|
+
from .pagination import PageableResource
|
|
22
|
+
from .reporting import (
|
|
23
|
+
ErrorReporter,
|
|
24
|
+
LoggingReporter,
|
|
25
|
+
NullReporter,
|
|
26
|
+
get_default_reporter,
|
|
27
|
+
set_default_reporter,
|
|
28
|
+
)
|
|
29
|
+
from .transport import PBNClientTransport, RequestsTransport
|
|
30
|
+
from .utils import smart_content
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"PBNClient",
|
|
34
|
+
"OAuthMixin",
|
|
35
|
+
"ConferencesMixin",
|
|
36
|
+
"DictionariesMixin",
|
|
37
|
+
"InstitutionsMixin",
|
|
38
|
+
"InstitutionsProfileMixin",
|
|
39
|
+
"JournalsMixin",
|
|
40
|
+
"PersonMixin",
|
|
41
|
+
"PublicationsMixin",
|
|
42
|
+
"PublishersMixin",
|
|
43
|
+
"SearchMixin",
|
|
44
|
+
"PageableResource",
|
|
45
|
+
"ErrorReporter",
|
|
46
|
+
"LoggingReporter",
|
|
47
|
+
"NullReporter",
|
|
48
|
+
"get_default_reporter",
|
|
49
|
+
"set_default_reporter",
|
|
50
|
+
"PBNClientTransport",
|
|
51
|
+
"RequestsTransport",
|
|
52
|
+
"smart_content",
|
|
53
|
+
]
|
pbn_client/auth.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""OAuth authentication mixin for PBN API."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import parse_qs, urlparse
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from pbn_client.conf import settings as pbn_settings
|
|
8
|
+
from pbn_client.exceptions import (
|
|
9
|
+
AuthenticationConfigurationError,
|
|
10
|
+
AuthenticationResponseError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OAuthMixin:
|
|
15
|
+
"""Mixin providing OAuth authentication functionality."""
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def get_auth_url(klass, base_url, app_id, state=None):
|
|
19
|
+
url = f"{base_url}/auth/pbn/api/registration/user/token/{app_id}"
|
|
20
|
+
if state:
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
url += f"?state={quote(state)}"
|
|
24
|
+
return url
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def get_user_token(
|
|
28
|
+
klass,
|
|
29
|
+
base_url,
|
|
30
|
+
app_id,
|
|
31
|
+
app_token,
|
|
32
|
+
one_time_token,
|
|
33
|
+
*,
|
|
34
|
+
timeout=None,
|
|
35
|
+
):
|
|
36
|
+
headers = {
|
|
37
|
+
"X-App-Id": app_id,
|
|
38
|
+
"X-App-Token": app_token,
|
|
39
|
+
}
|
|
40
|
+
body = {"oneTimeToken": one_time_token}
|
|
41
|
+
url = f"{base_url}/auth/pbn/api/user/token"
|
|
42
|
+
response = requests.post(
|
|
43
|
+
url=url,
|
|
44
|
+
json=body,
|
|
45
|
+
headers=headers,
|
|
46
|
+
timeout=(
|
|
47
|
+
pbn_settings.PBN_CLIENT_HTTP_TIMEOUT
|
|
48
|
+
if timeout is None
|
|
49
|
+
else pbn_settings.parse_timeout(timeout)
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
response.json()
|
|
54
|
+
except ValueError as e:
|
|
55
|
+
if response.content.startswith(b"Mismatched X-APP-TOKEN: "):
|
|
56
|
+
raise AuthenticationConfigurationError(
|
|
57
|
+
"Token aplikacji PBN nieprawidłowy. Poproś administratora "
|
|
58
|
+
"o skonfigurowanie prawidłowego tokena aplikacji PBN w "
|
|
59
|
+
"konfiguracji aplikacji. "
|
|
60
|
+
) from e
|
|
61
|
+
|
|
62
|
+
raise AuthenticationResponseError(response.content) from e
|
|
63
|
+
|
|
64
|
+
return response.json().get("X-User-Token")
|
|
65
|
+
|
|
66
|
+
def authorize(self, base_url, app_id, app_token):
|
|
67
|
+
if self.access_token:
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
self.access_token = pbn_settings.PBN_CLIENT_USER_TOKEN
|
|
71
|
+
if self.access_token:
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
auth_url = OAuthMixin.get_auth_url(base_url, app_id)
|
|
75
|
+
|
|
76
|
+
print(
|
|
77
|
+
f"""I have launched a web browser with {auth_url} ,\nplease log-in,
|
|
78
|
+
then paste the redirected URL below. \n"""
|
|
79
|
+
)
|
|
80
|
+
import webbrowser
|
|
81
|
+
|
|
82
|
+
webbrowser.open(auth_url)
|
|
83
|
+
redirect_response = input("Paste the full redirect URL here:")
|
|
84
|
+
one_time_token = parse_qs(urlparse(redirect_response).query).get("ott")[0]
|
|
85
|
+
|
|
86
|
+
# NIE wypisujemy one_time_token ani access_token — to aktywne sekrety,
|
|
87
|
+
# które zostawałyby w terminalu/CI/przechwyconych logach (uwaga #4).
|
|
88
|
+
self.access_token = OAuthMixin.get_user_token(
|
|
89
|
+
base_url,
|
|
90
|
+
app_id,
|
|
91
|
+
app_token,
|
|
92
|
+
one_time_token,
|
|
93
|
+
timeout=getattr(self, "timeout", None),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return True
|
pbn_client/client.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Czysty klient protokołu PBN.
|
|
2
|
+
|
|
3
|
+
``PBNClient`` to kompozycja mixinów protokołu (słownikowo-CRUD + silnik
|
|
4
|
+
oświadczeń ``StatementsMixin``). Operuje na tokenach, PBN UID-ach, JSON-ach
|
|
5
|
+
i flagach bool — nie zna modelu domenowego aplikacji-hosta.
|
|
6
|
+
|
|
7
|
+
Orkiestracja synchronizacji specyficzna dla aplikacji (znająca jej rekordy
|
|
8
|
+
i konfigurację instytucji) powinna żyć w warstwie aplikacji, jako klasa
|
|
9
|
+
dziedzicząca po ``PBNClient``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from pprint import pprint
|
|
15
|
+
|
|
16
|
+
from .mixins import (
|
|
17
|
+
ConferencesMixin,
|
|
18
|
+
DictionariesMixin,
|
|
19
|
+
InstitutionsMixin,
|
|
20
|
+
InstitutionsProfileMixin,
|
|
21
|
+
JournalsMixin,
|
|
22
|
+
PersonMixin,
|
|
23
|
+
PublicationsMixin,
|
|
24
|
+
PublishersMixin,
|
|
25
|
+
SearchMixin,
|
|
26
|
+
)
|
|
27
|
+
from .statements import StatementsMixin
|
|
28
|
+
from .transport import RequestsTransport
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PBNClient(
|
|
32
|
+
ConferencesMixin,
|
|
33
|
+
DictionariesMixin,
|
|
34
|
+
InstitutionsMixin,
|
|
35
|
+
InstitutionsProfileMixin,
|
|
36
|
+
JournalsMixin,
|
|
37
|
+
PersonMixin,
|
|
38
|
+
PublicationsMixin,
|
|
39
|
+
PublishersMixin,
|
|
40
|
+
SearchMixin,
|
|
41
|
+
StatementsMixin,
|
|
42
|
+
):
|
|
43
|
+
"""Czysty klient protokołu PBN (bez orkiestracji specyficznej dla aplikacji)."""
|
|
44
|
+
|
|
45
|
+
_interactive = False
|
|
46
|
+
|
|
47
|
+
def __init__(self, transport: RequestsTransport):
|
|
48
|
+
self.transport = transport
|
|
49
|
+
|
|
50
|
+
def _get_command_function(self, cmd):
|
|
51
|
+
"""Get function to execute from command name."""
|
|
52
|
+
try:
|
|
53
|
+
return getattr(self, cmd[0])
|
|
54
|
+
except AttributeError as e:
|
|
55
|
+
if self._interactive:
|
|
56
|
+
print(f"No such command: {cmd}")
|
|
57
|
+
return None
|
|
58
|
+
raise e
|
|
59
|
+
|
|
60
|
+
def _extract_arguments(self, lst):
|
|
61
|
+
"""Extract positional and keyword arguments from command list."""
|
|
62
|
+
args = ()
|
|
63
|
+
kw = {}
|
|
64
|
+
for elem in lst:
|
|
65
|
+
if elem.find(":") >= 1:
|
|
66
|
+
k, n = elem.split(":", 1)
|
|
67
|
+
kw[k] = n
|
|
68
|
+
else:
|
|
69
|
+
args += (elem,)
|
|
70
|
+
return args, kw
|
|
71
|
+
|
|
72
|
+
def _print_non_interactive_result(self, res):
|
|
73
|
+
"""Print result in non-interactive mode."""
|
|
74
|
+
import json
|
|
75
|
+
|
|
76
|
+
print(json.dumps(res))
|
|
77
|
+
|
|
78
|
+
def _print_interactive_result(self, res):
|
|
79
|
+
"""Print result in interactive mode."""
|
|
80
|
+
if type(res) is dict:
|
|
81
|
+
pprint(res)
|
|
82
|
+
elif isinstance(res, Iterable):
|
|
83
|
+
if self._interactive and hasattr(res, "total_elements"):
|
|
84
|
+
print(
|
|
85
|
+
"Incoming data: no_elements=",
|
|
86
|
+
res.total_elements,
|
|
87
|
+
"no_pages=",
|
|
88
|
+
res.total_pages,
|
|
89
|
+
)
|
|
90
|
+
input("Press ENTER to continue> ")
|
|
91
|
+
for elem in res:
|
|
92
|
+
pprint(elem)
|
|
93
|
+
|
|
94
|
+
def exec(self, cmd):
|
|
95
|
+
fun = self._get_command_function(cmd)
|
|
96
|
+
if fun is None:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
args, kw = self._extract_arguments(cmd[1:])
|
|
100
|
+
res = fun(*args, **kw)
|
|
101
|
+
|
|
102
|
+
if not sys.stdout.isatty():
|
|
103
|
+
self._print_non_interactive_result(res)
|
|
104
|
+
else:
|
|
105
|
+
self._print_interactive_result(res)
|
|
106
|
+
|
|
107
|
+
def interactive(self):
|
|
108
|
+
self._interactive = True
|
|
109
|
+
while True:
|
|
110
|
+
cmd = input("cmd> ")
|
|
111
|
+
if cmd == "exit":
|
|
112
|
+
break
|
|
113
|
+
self.exec(cmd.split(" "))
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Environment defaults for the standalone PBN client.
|
|
2
|
+
|
|
3
|
+
Applications should normally pass credentials and timeouts to the transport
|
|
4
|
+
constructor. These variables remain available for command-line compatibility.
|
|
5
|
+
No framework settings object is imported here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from pbn_client.const import DEFAULT_BASE_URL
|
|
14
|
+
|
|
15
|
+
Timeout = float | tuple[float, float]
|
|
16
|
+
DEFAULT_HTTP_TIMEOUT: Timeout = (30.0, 120.0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_timeout(
|
|
20
|
+
raw: str | float | Sequence[float] | None,
|
|
21
|
+
default: Timeout = DEFAULT_HTTP_TIMEOUT,
|
|
22
|
+
) -> Timeout:
|
|
23
|
+
"""Parse a requests timeout from a scalar or ``connect,read`` value."""
|
|
24
|
+
|
|
25
|
+
if raw is None:
|
|
26
|
+
return default
|
|
27
|
+
if isinstance(raw, (int, float)):
|
|
28
|
+
return float(raw)
|
|
29
|
+
if isinstance(raw, Sequence) and not isinstance(raw, str):
|
|
30
|
+
values = tuple(float(value) for value in raw)
|
|
31
|
+
return values if len(values) == 2 else default
|
|
32
|
+
|
|
33
|
+
parts = [part.strip() for part in str(raw).split(",") if part.strip()]
|
|
34
|
+
if len(parts) == 1:
|
|
35
|
+
return float(parts[0])
|
|
36
|
+
if len(parts) == 2:
|
|
37
|
+
return (float(parts[0]), float(parts[1]))
|
|
38
|
+
return default
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PBN_CLIENT_DEFAULT_BASE_URL = DEFAULT_BASE_URL
|
|
42
|
+
PBN_CLIENT_APP_ID = os.getenv("PBN_CLIENT_APP_ID")
|
|
43
|
+
PBN_CLIENT_APP_TOKEN = os.getenv("PBN_CLIENT_APP_TOKEN")
|
|
44
|
+
PBN_CLIENT_BASE_URL = os.getenv("PBN_CLIENT_BASE_URL", DEFAULT_BASE_URL)
|
|
45
|
+
PBN_CLIENT_USER_TOKEN = os.getenv("PBN_CLIENT_USER_TOKEN")
|
|
46
|
+
PBN_CLIENT_HTTP_TIMEOUT = parse_timeout(os.getenv("PBN_CLIENT_HTTP_TIMEOUT"))
|
pbn_client/const.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
DELETED = "DELETED"
|
|
2
|
+
ACTIVE = "ACTIVE"
|
|
3
|
+
|
|
4
|
+
PBN_POST_PUBLICATIONS_URL = "/api/v1/publications"
|
|
5
|
+
|
|
6
|
+
PBN_POST_PUBLICATION_NO_STATEMENTS_URL = "/api/v1/repositorium/publications"
|
|
7
|
+
|
|
8
|
+
PBN_POST_PUBLICATION_FEE_URL = "/api/v1/institutionProfile/publications/fees/{id}"
|
|
9
|
+
|
|
10
|
+
PBN_GET_LANGUAGES_URL = "/api/v1/dictionary/languages"
|
|
11
|
+
PBN_SEARCH_PUBLICATIONS_URL = "/api/v1/search/publications"
|
|
12
|
+
|
|
13
|
+
PBN_GET_JOURNAL_BY_ID = "/api/v1/journals/{id}"
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://pbn-micro-alpha.opi.org.pl"
|
|
16
|
+
|
|
17
|
+
NEEDS_PBN_AUTH_MSG = (
|
|
18
|
+
"W celu poprawnej autentykacji należy podać poprawny token użytkownika aplikacji."
|
|
19
|
+
)
|
|
20
|
+
PBN_DELETE_PUBLICATION_STATEMENT = (
|
|
21
|
+
"/api/v1/institutionProfile/publications/{publicationId}"
|
|
22
|
+
)
|
|
23
|
+
PBN_GET_PUBLICATION_BY_ID_URL = "/api/v1/publications/id/{id}"
|
|
24
|
+
|
|
25
|
+
PBN_GET_INSTITUTION_STATEMENTS = (
|
|
26
|
+
"/api/v1/institutionProfile/publications/page/statements"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PBN_GET_DISCIPLINES_URL = "/api/v2/dictionary/disciplines"
|
|
30
|
+
|
|
31
|
+
PBN_POST_INSTITUTION_STATEMENTS_URL = "/api/v2/institution-profile/statements"
|
|
32
|
+
|
|
33
|
+
PBN_GET_INSTITUTION_PUBLICATIONS_V2 = "/api/v2/institution-profile/publications"
|
pbn_client/dict_utils.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
def rename_dict_key(data, old_key, new_key):
|
|
2
|
+
"""
|
|
3
|
+
Recursively rename a dictionary key in a dictionary and all nested dictionaries.
|
|
4
|
+
|
|
5
|
+
Args:
|
|
6
|
+
data: Dictionary or any data structure that may contain dictionaries
|
|
7
|
+
old_key: The key to be renamed
|
|
8
|
+
new_key: The new key name
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
The modified data structure with renamed keys
|
|
12
|
+
"""
|
|
13
|
+
if isinstance(data, dict):
|
|
14
|
+
# Create a new dictionary with renamed keys
|
|
15
|
+
new_dict = {}
|
|
16
|
+
for key, value in data.items():
|
|
17
|
+
# Rename the key if it matches
|
|
18
|
+
new_key_name = new_key if key == old_key else key
|
|
19
|
+
# Recursively process the value
|
|
20
|
+
new_dict[new_key_name] = rename_dict_key(value, old_key, new_key)
|
|
21
|
+
return new_dict
|
|
22
|
+
elif isinstance(data, list):
|
|
23
|
+
# Process each item in the list
|
|
24
|
+
return [rename_dict_key(item, old_key, new_key) for item in data]
|
|
25
|
+
else:
|
|
26
|
+
# Return the value unchanged if it's not a dict or list
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compare_dicts(d1, d2, path=""):
|
|
31
|
+
diffs = []
|
|
32
|
+
keys = set(d1.keys()) | set(d2.keys())
|
|
33
|
+
|
|
34
|
+
for key in keys:
|
|
35
|
+
key_path = f"{path}.{key}" if path else key
|
|
36
|
+
|
|
37
|
+
if key not in d1:
|
|
38
|
+
diffs.append(f"Key '{key_path}' missing in first dict")
|
|
39
|
+
elif key not in d2:
|
|
40
|
+
diffs.append(f"Key '{key_path}' missing in second dict")
|
|
41
|
+
else:
|
|
42
|
+
v1, v2 = d1[key], d2[key]
|
|
43
|
+
if isinstance(v1, dict) and isinstance(v2, dict):
|
|
44
|
+
diffs.extend(compare_dicts(v1, v2, key_path))
|
|
45
|
+
elif v1 != v2:
|
|
46
|
+
diffs.append(f"Value mismatch at '{key_path}': {v1!r} != {v2!r}")
|
|
47
|
+
|
|
48
|
+
return diffs
|
pbn_client/exceptions.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Exceptions raised by the framework-independent PBN protocol client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AccessDeniedException",
|
|
7
|
+
"AuthenticationConfigurationError",
|
|
8
|
+
"AuthenticationResponseError",
|
|
9
|
+
"CannotDeleteStatementsException",
|
|
10
|
+
"CannotUploadPublicationFee",
|
|
11
|
+
"HttpException",
|
|
12
|
+
"NeedsPBNAuthorisationException",
|
|
13
|
+
"PBNValidationError",
|
|
14
|
+
"PraceSerwisoweException",
|
|
15
|
+
"PublicationDoesNotExistInInstitutionProfile",
|
|
16
|
+
"ResourceLockedException",
|
|
17
|
+
"SciencistDoesNotExist",
|
|
18
|
+
"StatementsResendFailedException",
|
|
19
|
+
"parse_pbn_validation_details",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PraceSerwisoweException(Exception):
|
|
24
|
+
def __str__(self):
|
|
25
|
+
return "Po stronie PBN trwają prace serwisowe. Prosimy spróbować później. "
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CannotDeleteStatementsException(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HttpException(Exception):
|
|
33
|
+
def __init__(self, status_code, url, content):
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.url = url
|
|
36
|
+
self.content = content
|
|
37
|
+
try:
|
|
38
|
+
self.json = json.loads(content[:4096])
|
|
39
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
40
|
+
self.json = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ResourceLockedException(HttpException):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AccessDeniedException(Exception):
|
|
48
|
+
def __init__(self, url, content):
|
|
49
|
+
self.url = url
|
|
50
|
+
self.content = content
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SciencistDoesNotExist(Exception):
|
|
54
|
+
"""The requested scientist does not exist in PBN."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AuthenticationConfigurationError(Exception):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthenticationResponseError(Exception):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NeedsPBNAuthorisationException(HttpException):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CannotUploadPublicationFee(ValueError):
|
|
70
|
+
"""PBN rejected a fee because the publication has no fee obligation."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PublicationDoesNotExistInInstitutionProfile(ValueError):
|
|
74
|
+
"""The publication is missing from the PBN institution profile."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StatementsResendFailedException(Exception):
|
|
78
|
+
"""Statement synchronization failed after exhausting its retry policy."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, publication_pk, pbn_uid, last_error):
|
|
81
|
+
self.publication_pk = publication_pk
|
|
82
|
+
self.pbn_uid = pbn_uid
|
|
83
|
+
self.last_error = last_error
|
|
84
|
+
super().__init__(
|
|
85
|
+
f"Synchronizacja oświadczeń dla pracy pk={publication_pk} "
|
|
86
|
+
f"(PBN UID={pbn_uid}) nie powiodła się po wyczerpaniu prób: "
|
|
87
|
+
f"{last_error}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_pbn_validation_details(parsed_json):
|
|
92
|
+
"""Return deduplicated PBN validation messages or ``None``.
|
|
93
|
+
|
|
94
|
+
Recognizes both the object-shaped ``details`` response and the list-shaped
|
|
95
|
+
response containing ``message``, ``description`` or ``code`` fields.
|
|
96
|
+
Hostile values are converted to strings and malformed elements are skipped.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def _coerce(value):
|
|
100
|
+
if isinstance(value, (list, tuple)):
|
|
101
|
+
return ", ".join(str(item) for item in value)
|
|
102
|
+
return str(value)
|
|
103
|
+
|
|
104
|
+
messages = []
|
|
105
|
+
if isinstance(parsed_json, dict):
|
|
106
|
+
details = parsed_json.get("details")
|
|
107
|
+
if isinstance(details, dict) and details:
|
|
108
|
+
messages = [_coerce(value) for value in details.values()]
|
|
109
|
+
elif isinstance(parsed_json, list) and parsed_json:
|
|
110
|
+
for element in parsed_json:
|
|
111
|
+
if not isinstance(element, dict):
|
|
112
|
+
continue
|
|
113
|
+
text = (
|
|
114
|
+
element.get("message")
|
|
115
|
+
or element.get("description")
|
|
116
|
+
or element.get("code")
|
|
117
|
+
)
|
|
118
|
+
if text:
|
|
119
|
+
messages.append(_coerce(text))
|
|
120
|
+
|
|
121
|
+
if not messages:
|
|
122
|
+
return None
|
|
123
|
+
return list(dict.fromkeys(messages))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PBNValidationError(HttpException):
|
|
127
|
+
"""PBN rejected user data with a validation response.
|
|
128
|
+
|
|
129
|
+
``__str__`` intentionally remains the inherited ``HttpException`` form:
|
|
130
|
+
consumers that parse the original exception tuple rely on the raw
|
|
131
|
+
response body remaining available.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, status_code, url, content):
|
|
135
|
+
super().__init__(status_code, url, content)
|
|
136
|
+
self.messages = parse_pbn_validation_details(self.json) or []
|
|
137
|
+
|
|
138
|
+
def user_messages(self):
|
|
139
|
+
"""Return deduplicated messages suitable for display to a user."""
|
|
140
|
+
|
|
141
|
+
return self.messages
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""API endpoint mixins for PBN client."""
|
|
2
|
+
|
|
3
|
+
from .conferences import ConferencesMixin
|
|
4
|
+
from .dictionaries import DictionariesMixin
|
|
5
|
+
from .institutions import InstitutionsMixin, InstitutionsProfileMixin
|
|
6
|
+
from .journals import JournalsMixin
|
|
7
|
+
from .person import PersonMixin
|
|
8
|
+
from .publications import PublicationsMixin
|
|
9
|
+
from .publishers import PublishersMixin
|
|
10
|
+
from .search import SearchMixin
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ConferencesMixin",
|
|
14
|
+
"DictionariesMixin",
|
|
15
|
+
"InstitutionsMixin",
|
|
16
|
+
"InstitutionsProfileMixin",
|
|
17
|
+
"JournalsMixin",
|
|
18
|
+
"PersonMixin",
|
|
19
|
+
"PublicationsMixin",
|
|
20
|
+
"PublishersMixin",
|
|
21
|
+
"SearchMixin",
|
|
22
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Conferences API mixin."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ConferencesMixin:
|
|
5
|
+
"""Mixin providing conference-related API methods."""
|
|
6
|
+
|
|
7
|
+
def get_conferences(self, *args, **kw):
|
|
8
|
+
return self.transport.get_pages("/api/v1/conferences/page", *args, **kw)
|
|
9
|
+
|
|
10
|
+
def get_conferences_mnisw(self, *args, **kw):
|
|
11
|
+
return self.transport.get_pages("/api/v1/conferences/mnisw/page", *args, **kw)
|
|
12
|
+
|
|
13
|
+
def get_conference(self, id):
|
|
14
|
+
return self.transport.get(f"/api/v1/conferences/{id}")
|
|
15
|
+
|
|
16
|
+
def get_conference_editions(self, id):
|
|
17
|
+
return self.transport.get(f"/api/v1/conferences/{id}/editions")
|
|
18
|
+
|
|
19
|
+
def get_conference_metadata(self, id):
|
|
20
|
+
return self.transport.get(f"/api/v1/conferences/{id}/metadata")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Dictionaries API mixin."""
|
|
2
|
+
|
|
3
|
+
from pbn_client.const import PBN_GET_DISCIPLINES_URL, PBN_GET_LANGUAGES_URL
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DictionariesMixin:
|
|
7
|
+
"""Mixin providing dictionary-related API methods."""
|
|
8
|
+
|
|
9
|
+
def get_countries(self):
|
|
10
|
+
return self.transport.get("/api/v1/dictionary/countries")
|
|
11
|
+
|
|
12
|
+
def get_disciplines(self):
|
|
13
|
+
return self.transport.get(PBN_GET_DISCIPLINES_URL)
|
|
14
|
+
|
|
15
|
+
def get_languages(self):
|
|
16
|
+
return self.transport.get(PBN_GET_LANGUAGES_URL)
|