Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 7 commits into from
Feb 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 50 additions & 21 deletions cvat-sdk/cvat_sdk/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@

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
Expand All @@ -24,13 +24,16 @@
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
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:
Expand Down Expand Up @@ -95,6 +98,37 @@ def __init__(
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
Expand Down Expand Up @@ -244,45 +278,40 @@ def get_server_version(self) -> pv.Version:
(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:
Expand Down
37 changes: 37 additions & 0 deletions cvat-sdk/cvat_sdk/core/proxies/organizations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably it will be reasonable to add invitations and memberships as well, because it's quite hard to work with orgs without this. I think, just adding the bases with no extra methods is enough for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair point. I'll take a look.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out, dealing with invitations is tricky, because they're identified by key instead of ID, which breaks some assumptions in the model mixins. I'll try to solve it, but maybe we can postpone that until the next PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, sounds reasonable.

34 changes: 33 additions & 1 deletion site/content/en/docs/api_sdk/sdk/highlevel-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
zhiltsov-max marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Down
48 changes: 47 additions & 1 deletion tests/python/sdk/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

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
Expand Down Expand Up @@ -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"
84 changes: 84 additions & 0 deletions tests/python/sdk/test_organizations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 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()