Labels came from a fixed table matched by subset, so Storage (no bits) matched any level the table lacked, and a level was named after the first entry it contained: the server's Brain/MINDS 2.0 folders (0x10000) showed as Storage and 0x10104 as CBS or PW Open. Labels are now composed from the bits. Projects (bit 16 and up) take the names the server reports to their members through users/current/, and otherwise Project<n>; restrict slot 1 is CBS and any other Restrict<n>. A level with no meaning is shown as Unknown (0x...). chacl reads keys built the same way, refuses public and storage before any request, and lists the user's projects with their keys when given an invalid level.
100 lines
3.9 KiB
Python
100 lines
3.9 KiB
Python
from dataclasses import field
|
|
from typing import Final
|
|
|
|
import requests
|
|
from pydantic import TypeAdapter
|
|
from pydantic.dataclasses import dataclass
|
|
|
|
from mdrsclient.api.base import BaseApi
|
|
from mdrsclient.exceptions import UnauthorizedException
|
|
from mdrsclient.models import Token, User
|
|
|
|
# (connect, read) seconds for the token refresh. Uploads are served by a separate
|
|
# instance, so this one is not queued behind them and has no reason to be slow.
|
|
TOKEN_REFRESH_TIMEOUT: Final[tuple[float, float]] = (5.0, 30.0)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UsersCurrentResponseLaboratory:
|
|
id: int
|
|
name: str
|
|
role: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UsersCurrentResponseProject:
|
|
id: int
|
|
name: str | None
|
|
bit_position: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UsersApiCurrentResponse:
|
|
id: int
|
|
username: str
|
|
first_name: str
|
|
last_name: str
|
|
email: str
|
|
orcid_id: str
|
|
laboratories: list[UsersCurrentResponseLaboratory]
|
|
is_staff: bool
|
|
is_active: bool
|
|
is_superuser: bool
|
|
is_reviewer: bool
|
|
last_login: str # ISO8601
|
|
date_joined: str # ISO8601
|
|
projects: list[UsersCurrentResponseProject] = field(default_factory=list)
|
|
|
|
|
|
class UsersApi(BaseApi):
|
|
ENTRYPOINT: Final[str] = "v3/users/"
|
|
|
|
def current(self) -> User:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + "current/"
|
|
response = self.connection.get(url)
|
|
self._raise_response_error(response)
|
|
obj = TypeAdapter(UsersApiCurrentResponse).validate_python(response.json())
|
|
laboratory_ids = list(map(lambda x: x.id, obj.laboratories))
|
|
user = User(id=obj.id, username=obj.username, laboratory_ids=laboratory_ids, is_reviewer=obj.is_reviewer)
|
|
return user
|
|
|
|
def project_names(self) -> dict[int, str]:
|
|
"""Names of the user's projects by bit_position, the bit their access level sets."""
|
|
url = self.ENTRYPOINT + "current/"
|
|
response = self.connection.get(url)
|
|
self._raise_response_error(response)
|
|
obj = TypeAdapter(UsersApiCurrentResponse).validate_python(response.json())
|
|
names: dict[int, list[str]] = {}
|
|
for project in obj.projects:
|
|
if project.name:
|
|
# bit_position is not unique on the server, and a folder at a shared bit
|
|
# belongs to every project holding it, so all of them are named.
|
|
names.setdefault(project.bit_position, []).append(project.name)
|
|
return {bit: ", ".join(sorted(x)) for bit, x in names.items()}
|
|
|
|
def token(self, username: str, password: str) -> Token:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + "token/"
|
|
data: dict[str, str | int] = {"username": username, "password": password}
|
|
response = self.connection.post(url, data=data)
|
|
if response.status_code == requests.codes.unauthorized:
|
|
raise UnauthorizedException("Invalid username or password.")
|
|
self._raise_response_error(response)
|
|
token = TypeAdapter(Token).validate_python(response.json())
|
|
return token
|
|
|
|
def tokenRefresh(self, token: Token) -> Token:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + "token/refresh/"
|
|
data: dict[str, str | int] = {"refresh": token.refresh}
|
|
# Bounded on its own: the caller holds a lock that spans processes while this runs,
|
|
# so a provider that accepts the connection and then goes quiet would otherwise
|
|
# stall every other request on this machine rather than just this one.
|
|
response = self.connection.post(url, data=data, timeout=TOKEN_REFRESH_TIMEOUT)
|
|
if response.status_code == requests.codes.unauthorized:
|
|
raise UnauthorizedException("Token is invalid or expired.")
|
|
self._raise_response_error(response)
|
|
token = TypeAdapter(Token).validate_python(response.json())
|
|
return token
|