未验证 提交 e816bbf6 编写于 作者: R Roman Donchenko 提交者: GitHub

SDK: Add organization support to the high-level layer (#5718)

This consists of two parts:

* API to work with organizations;
* API to work with other resources in the context of an organization.
上级 f88ee3fe
......@@ -27,6 +27,8 @@ Tracks can be exported/imported to/from Datumaro and Sly Pointcloud formats (<ht
- \[Server API\] Simple filters for object collection endpoints
(<https://github.com/opencv/cvat/pull/5575>)
- Analytics based on Clickhouse, Vector and Grafana instead of the ELK stack (<https://github.com/opencv/cvat/pull/5646>)
- \[SDK\] High-level API for working with organizations
(<https://github.com/opencv/cvat/pull/5718>)
### Changed
- The Docker Compose files now use the Compose Specification version
......
......@@ -7,10 +7,10 @@ from __future__ import annotations
import logging
import urllib.parse
from contextlib import suppress
from contextlib import contextmanager, suppress
from pathlib import Path
from time import sleep
from typing import Any, Dict, Optional, Sequence, Tuple
from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TypeVar
import attrs
import packaging.version as pv
......@@ -24,6 +24,7 @@ from cvat_sdk.core.helpers import expect_status
from cvat_sdk.core.proxies.issues import CommentsRepo, IssuesRepo
from cvat_sdk.core.proxies.jobs import JobsRepo
from cvat_sdk.core.proxies.model_proxy import Repo
from cvat_sdk.core.proxies.organizations import OrganizationsRepo
from cvat_sdk.core.proxies.projects import ProjectsRepo
from cvat_sdk.core.proxies.tasks import TasksRepo
from cvat_sdk.core.proxies.users import UsersRepo
......@@ -31,6 +32,8 @@ from cvat_sdk.version import VERSION
_DEFAULT_CACHE_DIR = platformdirs.user_cache_path("cvat-sdk", "CVAT.ai")
_RepoType = TypeVar("_RepoType", bound=Repo)
@attrs.define
class Config:
......@@ -95,6 +98,37 @@ class Client:
self._repos: Dict[str, Repo] = {}
"""A cache for created Repository instances"""
_ORG_SLUG_HEADER = "X-Organization"
@property
def organization_slug(self) -> Optional[str]:
"""
If this is set to a slug for an organization,
all requests will be made in the context of that organization.
If it's set to an empty string, requests will be made in the context
of the user's personal workspace.
If set to None (the default), no organization context will be used.
"""
return self.api_client.default_headers.get(self._ORG_SLUG_HEADER)
@organization_slug.setter
def organization_slug(self, org_slug: Optional[str]):
if org_slug is None:
self.api_client.default_headers.pop(self._ORG_SLUG_HEADER, None)
else:
self.api_client.default_headers[self._ORG_SLUG_HEADER] = org_slug
@contextmanager
def organization_context(self, slug: str) -> Iterator[None]:
prev_slug = self.organization_slug
self.organization_slug = slug
try:
yield
finally:
self.organization_slug = prev_slug
ALLOWED_SCHEMAS = ("https", "http")
@classmethod
......@@ -244,45 +278,40 @@ class Client:
(about, _) = self.api_client.server_api.retrieve_about()
return pv.Version(about.version)
def _get_repo(self, key: str) -> Repo:
_repo_map = {
"tasks": TasksRepo,
"projects": ProjectsRepo,
"jobs": JobsRepo,
"users": UsersRepo,
"issues": IssuesRepo,
"comments": CommentsRepo,
}
repo = self._repos.get(key, None)
def _get_repo(self, repo_type: _RepoType) -> _RepoType:
repo = self._repos.get(repo_type, None)
if repo is None:
repo = _repo_map[key](self)
self._repos[key] = repo
repo = repo_type(self)
self._repos[repo_type] = repo
return repo
@property
def tasks(self) -> TasksRepo:
return self._get_repo("tasks")
return self._get_repo(TasksRepo)
@property
def projects(self) -> ProjectsRepo:
return self._get_repo("projects")
return self._get_repo(ProjectsRepo)
@property
def jobs(self) -> JobsRepo:
return self._get_repo("jobs")
return self._get_repo(JobsRepo)
@property
def users(self) -> UsersRepo:
return self._get_repo("users")
return self._get_repo(UsersRepo)
@property
def organizations(self) -> OrganizationsRepo:
return self._get_repo(OrganizationsRepo)
@property
def issues(self) -> IssuesRepo:
return self._get_repo("issues")
return self._get_repo(IssuesRepo)
@property
def comments(self) -> CommentsRepo:
return self._get_repo("comments")
return self._get_repo(CommentsRepo)
class CVAT_API_V2:
......
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from __future__ import annotations
from cvat_sdk.api_client import apis, models
from cvat_sdk.core.proxies.model_proxy import (
ModelCreateMixin,
ModelDeleteMixin,
ModelListMixin,
ModelRetrieveMixin,
ModelUpdateMixin,
build_model_bases,
)
_OrganizationEntityBase, _OrganizationRepoBase = build_model_bases(
models.OrganizationRead, apis.OrganizationsApi, api_member_name="organizations_api"
)
class Organization(
models.IOrganizationRead,
_OrganizationEntityBase,
ModelUpdateMixin[models.IPatchedOrganizationWriteRequest],
ModelDeleteMixin,
):
_model_partial_update_arg = "patched_organization_write_request"
class OrganizationsRepo(
_OrganizationRepoBase,
ModelCreateMixin[Organization, models.IOrganizationWriteRequest],
ModelListMixin[Organization],
ModelRetrieveMixin[Organization],
):
_entity_type = Organization
......@@ -141,10 +141,42 @@ an error can be raised or suppressed (controlled by `config.allow_unsupported_se
If the error is suppressed, some SDK functions may not work as expected with this server.
By default, a warning is raised and the error is suppressed.
> Please note that all `Client` operations rely on the server API and depend on the current user
### Users and organizations
All `Client` operations rely on the server API and depend on the current user
rights. This affects the set of available APIs, objects and actions. For example, a regular user
can only see and modify their tasks and jobs, while an admin user can see all the tasks etc.
Operations are also affected by the current organization context,
which can be set with the `organization_slug` property of `Client` instances.
The organization context affects which entities are visible,
and where new entities are created.
Set `organization_slug` to an organization's slug (short name)
to make subsequent operations work in the context of that organization:
```python
client.organization_slug = 'myorg'
# create a task in the organization
task = client.tasks.create_from_data(...)
```
You can also set `organization_slug` to an empty string
to work in the context of the user's personal workspace.
By default, it is set to `None`,
which means that both personal and organizational entities are visible,
while new entities are created in the personal workspace.
To temporarily set the organization slug, use the `organization_context` function:
```python
with client.organization_context('myorg'):
task = client.tasks.create_from_data(...)
# the slug is now reset to its previous value
```
## Entities and Repositories
_Entities_ represent objects on the server. They provide read access to object fields
......
......@@ -9,7 +9,8 @@ from typing import Tuple
import packaging.version as pv
import pytest
from cvat_sdk import Client
from cvat_sdk import Client, models
from cvat_sdk.api_client.exceptions import NotFoundException
from cvat_sdk.core.client import Config, make_client
from cvat_sdk.core.exceptions import IncompatibleVersionException, InvalidHostException
from cvat_sdk.exceptions import ApiException
......@@ -166,3 +167,48 @@ def test_can_control_ssl_verification_with_config(verify: bool):
client = Client(BASE_URL, config=config)
assert client.api_client.configuration.verify_ssl == verify
def test_organization_contexts(admin_user: str):
with make_client(BASE_URL, credentials=(admin_user, USER_PASS)) as client:
assert client.organization_slug is None
org = client.organizations.create(models.OrganizationWriteRequest(slug="testorg"))
# create a project in the personal workspace
client.organization_slug = ""
personal_project = client.projects.create(models.ProjectWriteRequest(name="Personal"))
assert personal_project.organization is None
# create a project in the organization
client.organization_slug = org.slug
org_project = client.projects.create(models.ProjectWriteRequest(name="Org"))
assert org_project.organization == org.id
# both projects should be visible with no context
client.organization_slug = None
client.projects.retrieve(personal_project.id)
client.projects.retrieve(org_project.id)
# only the personal project should be visible in the personal workspace
client.organization_slug = ""
client.projects.retrieve(personal_project.id)
with pytest.raises(NotFoundException):
client.projects.retrieve(org_project.id)
# only the organizational project should be visible in the organization
client.organization_slug = org.slug
client.projects.retrieve(org_project.id)
with pytest.raises(NotFoundException):
client.projects.retrieve(personal_project.id)
def test_organization_context_manager():
client = Client(BASE_URL)
client.organization_slug = "abc"
with client.organization_context("def"):
assert client.organization_slug == "def"
assert client.organization_slug == "abc"
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from logging import Logger
from typing import Tuple
import pytest
from cvat_sdk import Client, models
from cvat_sdk.api_client import exceptions
from cvat_sdk.core.proxies.organizations import Organization
class TestOrganizationUsecases:
@pytest.fixture(autouse=True)
def setup(
self,
fxt_login: Tuple[Client, str],
fxt_logger: Tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
):
logger, self.logger_stream = fxt_logger
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
yield
assert fxt_stdout.getvalue() == ""
@pytest.fixture()
def fxt_organization(self) -> Organization:
org = self.client.organizations.create(
models.OrganizationWriteRequest(
slug="testorg",
name="Test Organization",
description="description",
contact={"email": "nowhere@cvat.invalid"},
)
)
try:
yield org
finally:
# It's not allowed to create multiple orgs with the same slug,
# so we have to remove the org at the end of each test.
org.remove()
def test_can_create_organization(self, fxt_organization: Organization):
assert fxt_organization.slug == "testorg"
assert fxt_organization.name == "Test Organization"
assert fxt_organization.description == "description"
assert fxt_organization.contact == {"email": "nowhere@cvat.invalid"}
def test_can_retrieve_organization(self, fxt_organization: Organization):
org = self.client.organizations.retrieve(fxt_organization.id)
assert org.id == fxt_organization.id
assert org.slug == fxt_organization.slug
def test_can_list_organizations(self, fxt_organization: Organization):
orgs = self.client.organizations.list()
assert fxt_organization.slug in set(o.slug for o in orgs)
def test_can_update_organization(self, fxt_organization: Organization):
fxt_organization.update(
models.PatchedOrganizationWriteRequest(description="new description")
)
assert fxt_organization.description == "new description"
retrieved_org = self.client.organizations.retrieve(fxt_organization.id)
assert retrieved_org.description == "new description"
def test_can_remove_organization(self):
org = self.client.organizations.create(models.OrganizationWriteRequest(slug="testorg2"))
org.remove()
with pytest.raises(exceptions.NotFoundException):
org.fetch()
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册