Skip to content

Commit c33f4a9

Browse files
authored
Merge pull request #231 from MerginMaps/v2_endpoints_for_user_and_access_management
V2 endpoints for user and access management
2 parents fa44044 + e7aed8e commit c33f4a9

File tree

4 files changed

+264
-19
lines changed

4 files changed

+264
-19
lines changed

mergin/client.py

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import typing
1818
import warnings
1919

20-
from .common import ClientError, LoginError, InvalidProject, ErrorCode
20+
from typing import List
21+
22+
from .common import ClientError, LoginError, WorkspaceRole, ProjectRole
2123
from .merginproject import MerginProject
2224
from .client_pull import (
2325
download_file_finalize,
@@ -36,6 +38,7 @@
3638
from .version import __version__
3739

3840
this_dir = os.path.dirname(os.path.realpath(__file__))
41+
json_headers = {"Content-Type": "application/json"}
3942

4043

4144
class TokenError(Exception):
@@ -207,9 +210,23 @@ def _do_request(self, request):
207210
except urllib.error.HTTPError as e:
208211
server_response = json.load(e)
209212

210-
# We first to try to get the value from the response otherwise we set a default value
211-
err_detail = server_response.get("detail", e.read().decode("utf-8"))
212-
server_code = server_response.get("code", None)
213+
err_detail = None
214+
server_code = None
215+
# Try to get error detail
216+
if isinstance(server_response, dict):
217+
server_code = server_response.get("code")
218+
err_detail = server_response.get("detail")
219+
if not err_detail:
220+
# Extract all field-specific errors and format them
221+
err_detail = "\n".join(
222+
f"{key}: {', '.join(map(str, value))}"
223+
for key, value in server_response.items()
224+
if isinstance(value, list)
225+
) or str(
226+
server_response
227+
) # Fallback to raw response if structure is unexpected
228+
else:
229+
err_detail = str(server_response)
213230

214231
raise ClientError(
215232
detail=err_detail,
@@ -244,6 +261,11 @@ def patch(self, path, data=None, headers={}):
244261
request = urllib.request.Request(url, data, headers, method="PATCH")
245262
return self._do_request(request)
246263

264+
def delete(self, path):
265+
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
266+
request = urllib.request.Request(url, method="DELETE")
267+
return self._do_request(request)
268+
247269
def login(self, login, password):
248270
"""
249271
Authenticate login credentials and store session token
@@ -796,6 +818,12 @@ def add_user_permissions_to_project(self, project_path, usernames, permission_le
796818
if permission_level in ("writer", "owner", "editor", "reader"):
797819
access.get("readersnames").append(name)
798820
self.set_project_access(project_path, access)
821+
warnings.warn(
822+
"This method will be deprecated in the next major release (1.0.0)"
823+
"Use `add_project_collaborator` to create a project permission and "
824+
"`update_project_collaborator` to change it instead.",
825+
category=DeprecationWarning,
826+
)
799827

800828
def remove_user_permissions_from_project(self, project_path, usernames):
801829
"""
@@ -815,6 +843,11 @@ def remove_user_permissions_from_project(self, project_path, usernames):
815843
if name in access.get("readersnames", []):
816844
access.get("readersnames").remove(name)
817845
self.set_project_access(project_path, access)
846+
warnings.warn(
847+
"This method will be deprecated in the next major release (1.0.0)"
848+
"Use `remove_project_collaborator` instead.",
849+
category=DeprecationWarning,
850+
)
818851

819852
def project_user_permissions(self, project_path):
820853
"""
@@ -1228,3 +1261,102 @@ def has_editor_support(self):
12281261
Returns whether the server version is acceptable for editor support.
12291262
"""
12301263
return is_version_acceptable(self.server_version(), "2024.4.0")
1264+
1265+
def create_user(
1266+
self,
1267+
email: str,
1268+
password: str,
1269+
workspace_id: int,
1270+
workspace_role: WorkspaceRole,
1271+
username: str = None,
1272+
notify_user: bool = False,
1273+
) -> dict:
1274+
"""
1275+
Create a new user in a workspace. The username is generated from the email address.
1276+
1277+
param email: email of the new user - must be unique
1278+
param password: password - must meet the requirements
1279+
param workspace_id: id of the workspace user is created in
1280+
param workspace_role: workspace role of the user
1281+
param username: username - will be autogenerated from the email if not provided
1282+
param notify_user: flag for email notifications - confirmation email will be sent
1283+
"""
1284+
params = {
1285+
"email": email,
1286+
"password": password,
1287+
"workspace_id": workspace_id,
1288+
"role": workspace_role.value,
1289+
"notify_user": notify_user,
1290+
}
1291+
if username:
1292+
params["username"] = username
1293+
user_info = self.post("v2/users", params, json_headers)
1294+
return json.load(user_info)
1295+
1296+
def get_workspace_member(self, workspace_id: int, user_id: int) -> dict:
1297+
"""
1298+
Get a workspace member detail
1299+
"""
1300+
resp = self.get(f"v2/workspaces/{workspace_id}/members/{user_id}")
1301+
return json.load(resp)
1302+
1303+
def list_workspace_members(self, workspace_id: int) -> List[dict]:
1304+
"""
1305+
Get a list of workspace members
1306+
"""
1307+
resp = self.get(f"v2/workspaces/{workspace_id}/members")
1308+
return json.load(resp)
1309+
1310+
def update_workspace_member(
1311+
self, workspace_id: int, user_id: int, workspace_role: WorkspaceRole, reset_projects_roles: bool = False
1312+
) -> dict:
1313+
"""
1314+
Update workspace role of a workspace member, optionally resets the projects role
1315+
1316+
param reset_projects_roles: all project specific roles will be removed
1317+
"""
1318+
params = {
1319+
"reset_projects_roles": reset_projects_roles,
1320+
"workspace_role": workspace_role.value,
1321+
}
1322+
workspace_member = self.patch(f"v2/workspaces/{workspace_id}/members/{user_id}", params, json_headers)
1323+
return json.load(workspace_member)
1324+
1325+
def remove_workspace_member(self, workspace_id: int, user_id: int):
1326+
"""
1327+
Remove a user from workspace members
1328+
"""
1329+
self.delete(f"v2/workspaces/{workspace_id}/members/{user_id}")
1330+
1331+
def list_project_collaborators(self, project_id: int) -> List[dict]:
1332+
"""
1333+
Get a list of project collaborators
1334+
"""
1335+
project_collaborators = self.get(f"v2/projects/{project_id}/collaborators")
1336+
return json.load(project_collaborators)
1337+
1338+
def add_project_collaborator(self, project_id: int, user: str, project_role: ProjectRole) -> dict:
1339+
"""
1340+
Add a user to project collaborators and grant them a project role.
1341+
Fails if user is already a member of the project.
1342+
1343+
param user: login (username or email) of the user
1344+
"""
1345+
params = {"role": project_role.value, "user": user}
1346+
project_collaborator = self.post(f"v2/projects/{project_id}/collaborators", params, json_headers)
1347+
return json.load(project_collaborator)
1348+
1349+
def update_project_collaborator(self, project_id: int, user_id: int, project_role: ProjectRole) -> dict:
1350+
"""
1351+
Update project role of the existing project collaborator.
1352+
Fails if user is not a member of the project yet.
1353+
"""
1354+
params = {"role": project_role.value}
1355+
project_collaborator = self.patch(f"v2/projects/{project_id}/collaborators/{user_id}", params, json_headers)
1356+
return json.load(project_collaborator)
1357+
1358+
def remove_project_collaborator(self, project_id: int, user_id: int):
1359+
"""
1360+
Remove a user from project collaborators
1361+
"""
1362+
self.delete(f"v2/projects/{project_id}/collaborators/{user_id}")

mergin/common.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,27 @@ class InvalidProject(Exception):
6565

6666
import dateutil.parser
6767
from dateutil.tz import tzlocal
68+
69+
70+
class WorkspaceRole(Enum):
71+
"""
72+
Workspace roles
73+
"""
74+
75+
GUEST = "guest"
76+
READER = "reader"
77+
EDITOR = "editor"
78+
WRITER = "writer"
79+
ADMIN = "admin"
80+
OWNER = "owner"
81+
82+
83+
class ProjectRole(Enum):
84+
"""
85+
Project roles
86+
"""
87+
88+
READER = "reader"
89+
EDITOR = "editor"
90+
WRITER = "writer"
91+
OWNER = "owner"

mergin/editor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from itertools import filterfalse
2-
from typing import Callable
2+
from typing import Callable, Dict, List
33

44
from .utils import is_mergin_config, is_qgis_file, is_versioned_file
55

@@ -24,7 +24,7 @@ def is_editor_enabled(mc, project_info: dict) -> bool:
2424
return server_support and project_role == EDITOR_ROLE_NAME
2525

2626

27-
def _apply_editor_filters(changes: dict[str, list[dict]]) -> dict[str, list[dict]]:
27+
def _apply_editor_filters(changes: Dict[str, List[dict]]) -> Dict[str, List[dict]]:
2828
"""
2929
Applies editor-specific filters to the changes dictionary, removing any changes to files that are not in the editor's list of allowed files.
3030
@@ -40,7 +40,7 @@ def _apply_editor_filters(changes: dict[str, list[dict]]) -> dict[str, list[dict
4040
return changes
4141

4242

43-
def filter_changes(mc, project_info: dict, changes: dict[str, list[dict]]) -> dict[str, list[dict]]:
43+
def filter_changes(mc, project_info: dict, changes: Dict[str, List[dict]]) -> Dict[str, List[dict]]:
4444
"""
4545
Filters the given changes dictionary based on the editor's enabled state.
4646

0 commit comments

Comments
 (0)