chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:23 +08:00
commit 10df941303
2913 changed files with 455804 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Copyright (C) 2022 Intel Corporation
#
# SPDX-License-Identifier: MIT
+905
View File
@@ -0,0 +1,905 @@
import io
import math
import os
from collections.abc import Mapping, Sequence
from functools import partial
from typing import IO
import numpy as np
import pytest
from attrs.converters import to_bool
from cvat_sdk.api_client import models
from PIL import Image
from pytest_cases import fixture, fixture_ref, parametrize
import shared.utils.s3 as s3
from rest_api.utils import calc_end_frame, create_task, iter_exclude, unique
from shared.fixtures.init import container_exec_cvat
from shared.tasks.enums import SourceDataType
from shared.tasks.interface import ITaskSpec
from shared.tasks.types import ImagesTaskSpec, VideoTaskSpec
from shared.tasks.utils import parse_frame_step
from shared.utils.config import SHARE_DIR, make_api_client
from shared.utils.helpers import generate_image_files, generate_video_file
def read_share_file(path: str) -> io.BytesIO:
data = io.BytesIO((SHARE_DIR / path).read_bytes())
data.name = path
return data
class TestTasksBase:
_USERNAME = "admin1"
def _image_task_fxt_base(
self,
request: pytest.FixtureRequest,
*,
frame_count: int | None = 10,
image_files: Sequence[io.BytesIO] | None = None,
related_files: Mapping[int, Sequence[io.BytesIO]] | None = None,
start_frame: int | None = None,
stop_frame: int | None = None,
step: int | None = None,
segment_size: int | None = None,
server_files: Sequence[str] | None = None,
cloud_storage_id: int | None = None,
job_replication: int | None = None,
**data_kwargs,
) -> tuple[ImagesTaskSpec, int]:
task_params = {
"name": f"{request.node.name}[{request.fixturename}]",
"labels": [{"name": "a"}],
**({"segment_size": segment_size} if segment_size else {}),
**({"consensus_replicas": job_replication} if job_replication else {}),
}
if server_files is not None:
assert (
image_files is not None
), "'server_files' must be used together with 'image_files'"
else:
assert bool(image_files) ^ bool(
frame_count
), "Expected only one of 'image_files' and 'frame_count'"
if not image_files:
image_files = generate_image_files(frame_count)
images_data = [f.getvalue() for f in image_files]
resulting_task_size = len(
range(start_frame or 0, (stop_frame or len(images_data) - 1) + 1, step or 1)
)
data_params = {
"image_quality": 70,
"sorting_method": "natural",
"chunk_size": max(1, (segment_size or resulting_task_size) // 2),
**(
{
"server_files": server_files,
"cloud_storage_id": cloud_storage_id,
}
if server_files
else {"client_files": image_files}
),
}
data_params.update(data_kwargs)
if start_frame is not None:
data_params["start_frame"] = start_frame
if stop_frame is not None:
data_params["stop_frame"] = stop_frame
if step is not None:
data_params["frame_filter"] = f"step={step}"
def get_frame(i: int) -> bytes:
return images_data[i]
if related_files is not None:
def get_related_files(i: int) -> Mapping[str, bytes]:
frame_ri = related_files.get(i)
common_prefix = ""
if frame_ri:
common_prefix = os.path.commonpath(os.path.dirname(f.name) for f in frame_ri)
return {os.path.relpath(f.name, common_prefix): f.getvalue() for f in frame_ri}
task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params)
return (
ImagesTaskSpec(
models.TaskWriteRequest._from_openapi_data(**task_params),
models.DataRequest._from_openapi_data(**data_params),
get_frame=get_frame,
get_related_files=get_related_files if related_files else None,
size=resulting_task_size,
),
task_id,
)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task(self, request: pytest.FixtureRequest) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(request=request)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task_with_segments(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(request=request, segment_size=4)
@fixture(scope="class")
@parametrize("step", [2, 5])
@parametrize("stop_frame", [15, 26])
@parametrize("start_frame", [3, 7])
def fxt_uploaded_images_task_with_segments_start_stop_step(
self, request: pytest.FixtureRequest, start_frame: int, stop_frame: int | None, step: int
) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(
request=request,
frame_count=30,
segment_size=4,
start_frame=start_frame,
stop_frame=stop_frame,
step=step,
)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task_with_segments_and_consensus(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(request=request, segment_size=4, job_replication=2)
def _image_task_with_honeypots_and_segments_base(
self,
request: pytest.FixtureRequest,
*,
start_frame: int | None = None,
step: int | None = None,
random_seed: int = 42,
image_files: Sequence[io.BytesIO] | None = None,
server_files: Sequence[str] | None = None,
cloud_storage_id: int | None = None,
**kwargs,
) -> tuple[ITaskSpec, int]:
validation_params = models.DataRequestValidationParams._from_openapi_data(
mode="gt_pool",
frame_selection_method="random_uniform",
random_seed=random_seed,
frame_count=5,
frames_per_job_count=2,
)
used_frames_count = 15
total_frame_count = (start_frame or 0) + used_frames_count * (step or 1)
base_segment_size = 4
regular_frame_count = used_frames_count - validation_params.frame_count
final_segment_size = base_segment_size + validation_params.frames_per_job_count
final_task_size = (
regular_frame_count
+ validation_params.frames_per_job_count
* math.ceil(regular_frame_count / base_segment_size)
+ validation_params.frame_count
)
if image_files:
if len(image_files) != total_frame_count:
raise ValueError(
f"If provided, image_files must contain {total_frame_count} images"
)
else:
image_files = generate_image_files(total_frame_count)
task_spec, task_id = self._image_task_fxt_base(
request=request,
frame_count=None,
image_files=image_files,
segment_size=base_segment_size,
sorting_method="random",
start_frame=start_frame,
step=step,
validation_params=validation_params,
server_files=server_files,
cloud_storage_id=cloud_storage_id,
**kwargs,
)
# Get the actual frame order after the task is created
with make_api_client(self._USERNAME) as api_client:
task_meta, _ = api_client.tasks_api.retrieve_data_meta(task_id)
frame_map = [
next(i for i, f in enumerate(image_files) if f.name == frame_info.name)
for frame_info in task_meta.frames
]
_get_frame = task_spec._get_frame
task_spec._get_frame = lambda i: _get_frame(frame_map[i])
task_spec.size = final_task_size
task_spec._params.segment_size = final_segment_size
# These parameters are not applicable to the resulting task,
# they are only effective during task creation
if start_frame or step:
task_spec._data_params.start_frame = 0
task_spec._data_params.stop_frame = task_spec.size
task_spec._data_params.frame_filter = ""
return task_spec, task_id
@fixture(scope="class")
def fxt_uploaded_images_task_with_honeypots_and_segments(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._image_task_with_honeypots_and_segments_base(request)
@fixture(scope="class")
@parametrize("start_frame, step", [(2, 3)])
def fxt_uploaded_images_task_with_honeypots_and_segments_start_step(
self, request: pytest.FixtureRequest, start_frame: int | None, step: int | None
) -> tuple[ITaskSpec, int]:
return self._image_task_with_honeypots_and_segments_base(
request, start_frame=start_frame, step=step
)
def _images_task_with_honeypots_and_changed_real_frames_base(
self, request: pytest.FixtureRequest, **kwargs
):
task_spec, task_id = self._image_task_with_honeypots_and_segments_base(
request, start_frame=2, step=3, **kwargs
)
with make_api_client(self._USERNAME) as api_client:
validation_layout, _ = api_client.tasks_api.retrieve_validation_layout(task_id)
validation_frames = validation_layout.validation_frames
new_honeypot_real_frames = [
validation_frames[(validation_frames.index(f) + 1) % len(validation_frames)]
for f in validation_layout.honeypot_real_frames
]
api_client.tasks_api.partial_update_validation_layout(
task_id,
patched_task_validation_layout_write_request=(
models.PatchedTaskValidationLayoutWriteRequest(
frame_selection_method="manual",
honeypot_real_frames=new_honeypot_real_frames,
)
),
)
# Get the new frame order
frame_map = dict(zip(validation_layout.honeypot_frames, new_honeypot_real_frames))
_get_frame = task_spec._get_frame
task_spec._get_frame = lambda i: _get_frame(frame_map.get(i, i))
return task_spec, task_id
@fixture(scope="class")
@parametrize("random_seed", [1, 2, 5])
def fxt_uploaded_images_task_with_honeypots_and_changed_real_frames(
self, request: pytest.FixtureRequest, random_seed: int
) -> tuple[ITaskSpec, int]:
return self._images_task_with_honeypots_and_changed_real_frames_base(
request, random_seed=random_seed
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(2, marks=[pytest.mark.with_external_services, pytest.mark.timeout(60)])],
)
def fxt_cloud_images_task_with_honeypots_and_changed_real_frames(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
image_files = generate_image_files(47)
for image in image_files:
image.name = f"test/{image.name}"
image.seek(0)
s3_client.create_file(data=image, filename=image.name)
request.addfinalizer(partial(s3_client.remove_file, filename=image.name))
server_files = [f.name for f in image_files]
for image in image_files:
image.seek(0)
return self._images_task_with_honeypots_and_changed_real_frames_base(
request,
image_files=image_files,
server_files=server_files,
cloud_storage_id=cloud_storage_id,
# FIXME: random sorting with frame filter and cloud images (and, optionally, honeypots)
# doesn't work with static cache
# https://github.com/cvat-ai/cvat/issues/9021
use_cache=True,
)
def _uploaded_images_task_with_gt_and_segments_base(
self,
request: pytest.FixtureRequest,
*,
start_frame: int | None = None,
step: int | None = None,
frame_selection_method: str = "random_uniform",
job_replication: int | None = None,
) -> tuple[ITaskSpec, int]:
used_frames_count = 16
total_frame_count = (start_frame or 0) + used_frames_count * (step or 1)
segment_size = 5
image_files = generate_image_files(total_frame_count)
validation_params_kwargs = {"frame_selection_method": frame_selection_method}
if "random" in frame_selection_method:
validation_params_kwargs["random_seed"] = 42
if frame_selection_method == "random_uniform":
validation_frames_count = 10
validation_params_kwargs["frame_count"] = validation_frames_count
elif frame_selection_method == "random_per_job":
frames_per_job_count = 3
validation_params_kwargs["frames_per_job_count"] = frames_per_job_count
validation_frames_count = used_frames_count // segment_size + min(
used_frames_count % segment_size, frames_per_job_count
)
elif frame_selection_method == "manual":
validation_frames_count = 10
valid_frame_ids = range(
(start_frame or 0), (start_frame or 0) + used_frames_count * (step or 1), step or 1
)
rng = np.random.Generator(np.random.MT19937(seed=42))
validation_params_kwargs["frames"] = rng.choice(
[f.name for i, f in enumerate(image_files) if i in valid_frame_ids],
validation_frames_count,
replace=False,
).tolist()
else:
raise NotImplementedError
validation_params = models.DataRequestValidationParams._from_openapi_data(
mode="gt",
**validation_params_kwargs,
)
return self._image_task_fxt_base(
request=request,
frame_count=None,
image_files=image_files,
segment_size=segment_size,
sorting_method="natural",
start_frame=start_frame,
step=step,
validation_params=validation_params,
job_replication=job_replication,
)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task_with_gt_and_segments_and_consensus(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._uploaded_images_task_with_gt_and_segments_base(
request=request, job_replication=2
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[
pytest.param(
1,
marks=[
pytest.mark.with_external_services,
pytest.mark.timeout(60),
pytest.mark.xfail(
to_bool(os.getenv("CVAT_ALLOW_STATIC_CACHE", False)),
reason="Creating a task from a cloud .bin file with static cache doesn't work at the moment",
),
],
)
],
)
def fxt_cloud_bin_pointcloud_task(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
server_files = ["bin_pointcloud/000002.bin"]
bin_files = []
for filename in server_files:
bin_file = io.BytesIO(s3_client.download_fileobj(filename))
bin_file.name = filename
bin_files.append(bin_file)
return self._image_task_fxt_base(
request,
image_files=bin_files,
server_files=server_files,
cloud_storage_id=cloud_storage_id,
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(2, marks=[pytest.mark.with_external_services, pytest.mark.timeout(60)])],
)
def fxt_cloud_images_task_with_related_images(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
image_files = generate_image_files(5)
def _upload_file(file: io.RawIOBase):
s3_client.create_file(data=file, filename=file.name)
request.addfinalizer(partial(s3_client.remove_file, filename=file.name))
related_files = []
for i, image in enumerate(image_files):
image.name = f"test/{image.name}"
image.seek(0)
_upload_file(image)
image_related_files = generate_image_files(i)
related_files.append(image_related_files)
for related_file in image_related_files:
assert related_file.name.endswith(".jpeg")
related_file.name = "{}/related_images/{}/{}".format(
os.path.dirname(image.name),
os.path.basename(image.name).replace(".", "_"),
related_file.name,
)
related_file.seek(0)
_upload_file(related_file)
server_files = [f.name for f in image_files] + [
f.name for rfs in related_files for f in rfs
]
for image in image_files:
image.seek(0)
return self._image_task_fxt_base(
request,
image_files=image_files,
related_files=dict(enumerate(related_files)),
server_files=server_files,
cloud_storage_id=cloud_storage_id,
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(1, marks=[pytest.mark.with_external_services, pytest.mark.timeout(60)])],
)
def fxt_cloud_pcd_task_with_related_images(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
server_files = [
"pcd_with_related/pointcloud/000001.pcd",
"pcd_with_related/pointcloud/000002.pcd",
"pcd_with_related/pointcloud/000003.pcd",
"pcd_with_related/related_images/000001_pcd/000001.png",
"pcd_with_related/related_images/000002_pcd/000002.png",
"pcd_with_related/related_images/000003_pcd/000003.png",
]
pcd_files = []
for filename in [
"pcd_with_related/pointcloud/000001.pcd",
"pcd_with_related/pointcloud/000002.pcd",
"pcd_with_related/pointcloud/000003.pcd",
]:
pcd_file = io.BytesIO(s3_client.download_fileobj(filename))
pcd_file.name = filename
pcd_files.append(pcd_file)
related_files = []
for filename in [
"pcd_with_related/related_images/000001_pcd/000001.png",
"pcd_with_related/related_images/000002_pcd/000002.png",
"pcd_with_related/related_images/000003_pcd/000003.png",
]:
ri_file = io.BytesIO(s3_client.download_fileobj(filename))
ri_file.name = os.path.basename(filename)
related_files.append([ri_file])
return self._image_task_fxt_base(
request,
image_files=pcd_files,
related_files=dict(enumerate(related_files)),
server_files=server_files,
cloud_storage_id=cloud_storage_id,
)
def _share_images_task_with_related_images(
self,
request: pytest.FixtureRequest,
**data_kwargs,
) -> tuple[ITaskSpec, int]:
image_files = [
read_share_file(fn)
for fn in [
"images/with_related/image_0.png",
"images/with_related/image_1.png",
"images/with_related/image_2.png",
"images/with_related/image_3.png",
]
]
related_files = {
0: [
"images/with_related/related_images/image_0_png/30.png",
"images/with_related/related_images/image_0_png/31.png",
"images/with_related/related_images/image_0_png/33.png",
],
1: [
"images/with_related/related_images/image_1_png/30.png",
],
2: [
"images/with_related/related_images/image_2_png/32.png",
"images/with_related/related_images/image_2_png/33.png",
"images/with_related/related_images/image_2_png/34.png",
],
3: [],
}
for k, fs in related_files.items():
related_files[k] = [read_share_file(fn) for fn in fs]
server_files = [f.name for f in image_files] + [
f.name for fs in related_files.values() for f in fs
]
return self._image_task_fxt_base(
request,
image_files=image_files,
related_files=related_files,
server_files=server_files,
**data_kwargs,
)
@fixture(scope="class")
def fxt_share_images_task_with_related_images(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._share_images_task_with_related_images(request)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(5, marks=[pytest.mark.with_external_services])],
)
def fxt_backing_cs_images_task_with_related_images(
self, request: pytest.FixtureRequest, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
# The simplest way to get a task with local storage and subdirectories is to use the share
# with copy_data.
task_spec, task_id = self._share_images_task_with_related_images(
request, copy_data=True, use_cache=True
)
container_exec_cvat(
request, ["./manage.py", "movetasktobackingcs", str(task_id), str(cloud_storage_id)]
)
return task_spec, task_id
@fixture(scope="class")
def fxt_share_pcd_task_with_related_images(
self,
request: pytest.FixtureRequest,
) -> tuple[ITaskSpec, int]:
pcd_files = [
read_share_file(fn)
for fn in [
"pcd_with_related/pointcloud/000001.pcd",
"pcd_with_related/pointcloud/000002.pcd",
"pcd_with_related/pointcloud/000003.pcd",
]
]
related_files = {
0: [
"pcd_with_related/related_images/000001_pcd/000001.png",
],
1: [
"pcd_with_related/related_images/000002_pcd/000002.png",
],
2: [
"pcd_with_related/related_images/000003_pcd/000003.png",
],
3: [],
}
for k, fs in related_files.items():
related_files[k] = [read_share_file(fn) for fn in fs]
server_files = [f.name for f in pcd_files] + [
f.name for fs in related_files.values() for f in fs
]
return self._image_task_fxt_base(
request,
image_files=pcd_files,
related_files=related_files,
server_files=server_files,
)
@fixture(scope="class")
@parametrize("start_frame, step", [(2, 3)])
@parametrize("frame_selection_method", ["random_uniform", "random_per_job", "manual"])
def fxt_uploaded_images_task_with_gt_and_segments_start_step(
self,
request: pytest.FixtureRequest,
start_frame: int | None,
step: int | None,
frame_selection_method: str,
) -> tuple[ITaskSpec, int]:
return self._uploaded_images_task_with_gt_and_segments_base(
request,
start_frame=start_frame,
step=step,
frame_selection_method=frame_selection_method,
)
def _uploaded_video_task_fxt_base(
self,
request: pytest.FixtureRequest,
*,
frame_count: int = 10,
segment_size: int | None = None,
start_frame: int | None = None,
stop_frame: int | None = None,
step: int | None = None,
video_file: IO[bytes] | None = None,
chapters: Sequence[dict] | None = None,
) -> tuple[VideoTaskSpec, int]:
task_params = {
"name": f"{request.node.name}[{request.fixturename}]",
"labels": [{"name": "a"}],
}
if segment_size:
task_params["segment_size"] = segment_size
resulting_task_size = len(
range(start_frame or 0, (stop_frame or frame_count - 1) + 1, step or 1)
)
assert (not video_file) ^ (
chapters is not None
), "Chapters must be specified with a custom video file"
if not video_file:
video_file = generate_video_file(frame_count)
video_data = video_file.getvalue()
chapters = [
{
"start": 0,
"stop": 5,
"metadata": {"title": "Intro"},
}
]
else:
video_data = video_file.read()
video_file.seek(0)
data_params = {
"image_quality": 70,
"client_files": [video_file],
"chunk_size": max(1, (segment_size or resulting_task_size) // 2),
}
if start_frame is not None:
data_params["start_frame"] = start_frame
if stop_frame is not None:
data_params["stop_frame"] = stop_frame
if step is not None:
data_params["frame_filter"] = f"step={step}"
def get_video_file() -> io.BytesIO:
return io.BytesIO(video_data)
task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params)
return (
VideoTaskSpec(
models.TaskWriteRequest._from_openapi_data(**task_params),
models.DataRequest._from_openapi_data(**data_params),
get_video_file=get_video_file,
size=resulting_task_size,
chapters=chapters,
),
task_id,
)
@pytest.fixture(scope="class")
def fxt_uploaded_video_task(
self,
request: pytest.FixtureRequest,
) -> tuple[ITaskSpec, int]:
return self._uploaded_video_task_fxt_base(request=request)
@pytest.fixture(scope="class")
def fxt_uploaded_video_task_without_manifest(
self,
request: pytest.FixtureRequest,
) -> tuple[ITaskSpec, int]:
video_file = generate_video_file(num_frames=10, invalid_keyframes=True)
return self._uploaded_video_task_fxt_base(
request=request, video_file=video_file, chapters=[]
)
@pytest.fixture(scope="class")
def fxt_uploaded_video_task_with_segments(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._uploaded_video_task_fxt_base(request=request, segment_size=4)
@fixture(scope="class")
@parametrize("step", [2, 5])
@parametrize("stop_frame", [15, 26])
@parametrize("start_frame", [3, 7])
def fxt_uploaded_video_task_with_segments_start_stop_step(
self, request: pytest.FixtureRequest, start_frame: int, stop_frame: int | None, step: int
) -> tuple[ITaskSpec, int]:
return self._uploaded_video_task_fxt_base(
request=request,
frame_count=30,
segment_size=4,
start_frame=start_frame,
stop_frame=stop_frame,
step=step,
)
def _compute_annotation_segment_params(self, task_spec: ITaskSpec) -> list[tuple[int, int]]:
segment_params = []
frame_step = task_spec.frame_step
segment_size = getattr(task_spec, "segment_size", 0) or task_spec.size * frame_step
start_frame = getattr(task_spec, "start_frame", 0)
stop_frame = getattr(task_spec, "stop_frame", None) or (
start_frame + (task_spec.size - 1) * frame_step
)
end_frame = calc_end_frame(start_frame, stop_frame, frame_step)
validation_params = getattr(task_spec, "validation_params", None)
if validation_params and validation_params.mode.value == "gt_pool":
end_frame = min(
end_frame, (task_spec.size - validation_params.frame_count) * frame_step
)
segment_size = min(segment_size, end_frame - 1)
overlap = min(
(
getattr(task_spec, "overlap", None) or 0
if task_spec.source_data_type == SourceDataType.images
else 5
),
segment_size // 2,
)
segment_start = start_frame
while segment_start < end_frame:
if start_frame < segment_start:
segment_start -= overlap * frame_step
segment_end = segment_start + frame_step * segment_size
segment_params.append((segment_start, min(segment_end, end_frame) - frame_step))
segment_start = segment_end
return segment_params
@staticmethod
def _compare_images(
expected: Image.Image, actual: Image.Image, *, must_be_identical: bool = True
):
expected_pixels = np.array(expected)
actual_pixels = np.array(actual)
assert expected_pixels.shape == actual_pixels.shape
if not must_be_identical:
# video chunks can have slightly changed colors, due to codec specifics
# compressed images can also be distorted
assert np.allclose(actual_pixels, expected_pixels, atol=3)
else:
assert np.array_equal(actual_pixels, expected_pixels)
def _get_job_abs_frame_set(self, job_meta: models.DataMetaRead) -> Sequence[int]:
if job_meta.included_frames:
return job_meta.included_frames
else:
return range(
job_meta.start_frame,
job_meta.stop_frame + 1,
parse_frame_step(job_meta.frame_filter),
)
_tasks_with_honeypots_cases = [
fixture_ref("fxt_uploaded_images_task_with_honeypots_and_segments"),
fixture_ref("fxt_uploaded_images_task_with_honeypots_and_segments_start_step"),
fixture_ref("fxt_uploaded_images_task_with_honeypots_and_changed_real_frames"),
fixture_ref("fxt_cloud_images_task_with_honeypots_and_changed_real_frames"),
]
_tasks_with_simple_gt_job_cases = [
fixture_ref("fxt_uploaded_images_task_with_gt_and_segments_start_step"),
fixture_ref("fxt_uploaded_images_task_with_gt_and_segments_and_consensus"),
]
_tasks_with_consensus_cases = [
fixture_ref("fxt_uploaded_images_task_with_segments_and_consensus"),
fixture_ref("fxt_uploaded_images_task_with_gt_and_segments_and_consensus"),
]
_tests_with_cloud_storage_cases = [
fixture_ref("fxt_cloud_bin_pointcloud_task"),
fixture_ref("fxt_cloud_images_task_with_honeypots_and_changed_real_frames"),
fixture_ref("fxt_cloud_images_task_with_related_images"),
]
_tests_with_share_cases = [
fixture_ref("fxt_share_images_task_with_related_images"),
fixture_ref("fxt_share_pcd_task_with_related_images"),
]
_tests_with_related_files_cases = [
fixture_ref("fxt_cloud_images_task_with_related_images"),
fixture_ref("fxt_cloud_pcd_task_with_related_images"),
fixture_ref("fxt_share_images_task_with_related_images"),
fixture_ref("fxt_share_pcd_task_with_related_images"),
fixture_ref("fxt_backing_cs_images_task_with_related_images"),
]
_3d_task_cases = [
fixture_ref("fxt_cloud_bin_pointcloud_task"),
fixture_ref("fxt_cloud_pcd_task_with_related_images"),
fixture_ref("fxt_share_pcd_task_with_related_images"),
]
# Keep in mind that these fixtures are generated eagerly
# (before each depending test or group of tests),
# e.g. a failing task creation in one the fixtures will fail all the depending tests cases.
_all_task_cases = unique(
[
fixture_ref("fxt_uploaded_images_task"),
fixture_ref("fxt_uploaded_images_task_with_segments"),
fixture_ref("fxt_uploaded_images_task_with_segments_start_stop_step"),
fixture_ref("fxt_uploaded_video_task"),
fixture_ref("fxt_uploaded_video_task_without_manifest"),
fixture_ref("fxt_uploaded_video_task_with_segments"),
fixture_ref("fxt_uploaded_video_task_with_segments_start_stop_step"),
]
+ _tasks_with_honeypots_cases
+ _tasks_with_simple_gt_job_cases
+ _tasks_with_consensus_cases
+ _tests_with_cloud_storage_cases
+ _tests_with_related_files_cases
+ _tests_with_share_cases,
key=lambda fxt_ref: fxt_ref.fixture,
)
_2d_task_cases = list(
iter_exclude(
_all_task_cases,
excludes=set(v.fixture for v in _3d_task_cases),
key=lambda v: v.fixture,
)
)
+480
View File
@@ -0,0 +1,480 @@
import csv
import json
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from io import StringIO
from time import sleep
import pytest
from cvat_sdk.api_client import exceptions, models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from pytest_cases import parametrize
from shared.utils.config import make_api_client
from .utils import CollectionSimpleFilterTestBase, export_backup, export_dataset, export_events
@pytest.mark.usefixtures("restore_db_per_function")
class TestPostAccessToken:
@pytest.mark.parametrize("user_group", ["admin", "user", "worker"])
def test_can_create_token(self, users, user_group):
user = next(u for u in users if user_group in u["groups"])
with make_api_client(user["username"]) as api_client:
token, _ = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(name="test token")
)
assert token.value
with make_api_client(access_token=token.value) as api_client:
user, _ = api_client.users_api.retrieve_self()
assert user.username == user["username"]
def test_can_create_token_with_expiration(self, admin_user):
expiration_date = datetime.now(tz=timezone.utc) - timedelta(seconds=1)
with make_api_client(admin_user) as api_client:
token, _ = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token", expiry_date=expiration_date
)
)
assert token.expiry_date == expiration_date
with (
make_api_client(access_token=token.value) as api_client,
pytest.raises(exceptions.UnauthorizedException, match="Invalid token"),
):
api_client.users_api.retrieve_self()
@parametrize("is_readonly", [True, False])
def test_can_create_readonly_token(self, admin_user, is_readonly, tasks):
with make_api_client(admin_user) as api_client:
token, _ = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token", read_only=is_readonly
)
)
assert token.read_only == is_readonly
with make_api_client(access_token=token.value) as api_client:
user, _ = api_client.users_api.retrieve_self()
assert user.username == admin_user
with ExitStack() as es:
if is_readonly:
es.enter_context(pytest.raises(exceptions.ForbiddenException))
api_client.tasks_api.partial_update(
next(iter(tasks))["id"],
patched_task_write_request=models.PatchedTaskWriteRequest(name="new name"),
)
class TestGetAccessToken:
def test_can_get_access_token(self, admin_user, access_tokens):
expected = next(iter(access_tokens))
with make_api_client(admin_user) as api_client:
_, response = api_client.auth_api.retrieve_access_tokens(expected["id"])
actual = json.loads(response.data)
assert DeepDiff(expected, actual, exclude_paths=["private_key"]) == {}
@parametrize("is_admin", [True, False])
def test_cannot_see_foreign_tokens(self, users, access_tokens_by_username, is_admin):
token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items()))
token = token_owner_tokens[0]
other_user = next(
u
for u in users
if u["username"] != token_owner
if u["is_superuser"] == is_admin
if not access_tokens_by_username.get(u["username"])
)
with make_api_client(other_user["username"]) as api_client:
_, response = api_client.auth_api.retrieve_access_tokens(
token["id"], _check_status=False
)
if is_admin:
assert response.status == HTTPStatus.OK
else:
assert response.status == HTTPStatus.FORBIDDEN
assert api_client.auth_api.list_access_tokens()[0].count == 0
def test_can_get_self(self, access_tokens_by_username):
_, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
with make_api_client(access_token=token["private_key"]) as api_client:
received_token = json.loads(api_client.auth_api.retrieve_access_tokens_self()[1].data)
assert (
DeepDiff(
token,
received_token,
exclude_paths=["updated_date", "last_used_date", "private_key"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.parametrize("token_eol_reason", ["expired", "stale", "revoked"])
def test_can_only_see_alive_tokens(self, token_eol_reason: str, admin_user):
with make_api_client(admin_user) as api_client:
if token_eol_reason == "expired":
token_id = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token",
expiry_date=datetime.now(timezone.utc) - timedelta(seconds=1),
)
)[0].id
elif token_eol_reason == "revoked":
token_id = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(name="test token")
)[0].id
api_client.auth_api.destroy_access_tokens(token_id)
elif token_eol_reason == "stale":
token_id = 6
else:
assert False, f"Unexpected token eol reason '{token_eol_reason}'"
with pytest.raises(
exceptions.NotFoundException, match="No AccessToken matches the given query"
):
api_client.auth_api.retrieve_access_tokens(token_id)
assert (
api_client.auth_api.list_access_tokens(
filter=json.dumps({"==": [{"var": "id"}, token_id]})
)[0].count
== 0
)
class TestAccessTokenListFilters(CollectionSimpleFilterTestBase):
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, users_by_name, raw_access_tokens_by_username):
# Only own keys are visible to each user
self.user, self.samples = next(
(username, user_tokens)
for username, user_tokens in raw_access_tokens_by_username.items()
if users_by_name[username]["is_superuser"] and user_tokens
)
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.auth_api.list_access_tokens_endpoint
@pytest.mark.parametrize("field", ("name", "read_only"))
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchAccessToken:
def test_can_modify_token(self, access_tokens_by_username):
user, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
updated_values = {
"name": "new name",
"expiry_date": datetime.now(timezone.utc) + timedelta(days=1),
"read_only": not token["read_only"],
}
updated_token = dict(token)
updated_token.update(updated_values)
with make_api_client(user) as api_client:
_, response = api_client.auth_api.partial_update_access_tokens(
token["id"],
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
**updated_values
),
)
received_token = json.loads(response.data)
updated_token["expiry_date"] = (
updated_token["expiry_date"].isoformat().replace("+00:00", "Z")
)
assert (
DeepDiff(updated_token, received_token, exclude_paths=["updated_date", "private_key"])
== {}
)
def test_cannot_modify_foreign_token(self, users, access_tokens_by_username):
token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items()))
token = token_owner_tokens[0]
other_user = next(
u["username"] for u in users if u["username"] != token_owner and not u["is_superuser"]
)
with (
make_api_client(other_user) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.partial_update_access_tokens(
token["id"],
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
name="new name"
),
)
def test_cannot_modify_expired_token(self, admin_user):
with make_api_client(admin_user) as api_client:
token_id = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token",
expiry_date=datetime.now(timezone.utc) - timedelta(seconds=1),
)
)[0].id
with pytest.raises(
exceptions.NotFoundException, match="No AccessToken matches the given query"
):
api_client.auth_api.partial_update_access_tokens(
token_id,
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
expiry_date=datetime.now(timezone.utc) + timedelta(days=1)
),
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteAccessToken:
def test_can_revoke_own_token(self, access_tokens_by_username):
user, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
with make_api_client(user) as api_client:
api_client.auth_api.destroy_access_tokens(token["id"])
def test_cannot_revoke_foreign_token(self, users, access_tokens_by_username):
token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items()))
token = token_owner_tokens[0]
other_user = next(
u["username"] for u in users if u["username"] != token_owner and not u["is_superuser"]
)
with (
make_api_client(other_user) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.destroy_access_tokens(token["id"])
class TestTokenAuthPermissions:
# Some operations are not allowed with token auth for security reasons, even if not read only
def test_cannot_change_password_when_using_token_auth(
self, admin_user, access_tokens_by_username
):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.UnauthorizedException),
):
api_client.auth_api.create_password_change(
password_change_request=models.PasswordChangeRequest(
old_password="any", new_password1="anypass1", new_password2="anypass1"
)
)
def test_cannot_create_token_when_using_token_auth(self, admin_user, access_tokens_by_username):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(name="test token")
)
def test_cannot_edit_token_when_using_token_auth(self, admin_user, access_tokens_by_username):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.partial_update_access_tokens(
token["id"],
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
name="new name"
),
)
def test_cannot_revoke_token_when_using_token_auth(self, admin_user, access_tokens_by_username):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.destroy_access_tokens(token["id"])
def test_cannot_edit_user_when_using_token_auth(
self, admin_user, access_tokens_by_username, users_by_name
):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
user = users_by_name[admin_user]
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.users_api.partial_update(
user["id"], patched_user_request=models.PatchedUserRequest(first_name="new name")
)
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@parametrize("is_readonly", [True, False])
@parametrize("export_type", ["annotations", "dataset", "backup"])
def test_token_can_export_project(
self, admin_user, access_tokens_by_username, projects, export_type, is_readonly
):
token = next(
t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly
)
project_id = next(p["id"] for p in projects if p["tasks"]["count"] > 0)
with make_api_client(access_token=token["private_key"]) as api_client:
if export_type in ("annotations", "dataset"):
export_dataset(
api_client.projects_api, id=project_id, save_images=(export_type == "dataset")
)
elif export_type == "backup":
export_backup(api_client.projects_api, id=project_id)
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@parametrize("is_readonly", [True, False])
@parametrize("export_type", ["annotations", "dataset", "backup"])
def test_token_can_export_task(
self, admin_user, access_tokens_by_username, tasks, export_type, is_readonly
):
token = next(
t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly
)
task_id = next(p["id"] for p in tasks if p["size"] > 0 if p["media_type"] != "audio")
with make_api_client(access_token=token["private_key"]) as api_client:
if export_type in ("annotations", "dataset"):
export_dataset(
api_client.tasks_api, id=task_id, save_images=(export_type == "dataset")
)
elif export_type == "backup":
export_backup(api_client.tasks_api, id=task_id)
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@parametrize("is_readonly", [True, False])
@parametrize("export_type", ["annotations", "dataset"])
def test_token_can_export_jobs(
self, admin_user, access_tokens_by_username, jobs, export_type, is_readonly
):
token = next(
t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly
)
job_id = next(p["id"] for p in jobs if p["media_type"] != "audio")
with make_api_client(access_token=token["private_key"]) as api_client:
if export_type in ("annotations", "dataset"):
export_dataset(
api_client.jobs_api, id=job_id, save_images=(export_type == "dataset")
)
elif export_type == "backup":
export_backup(api_client.jobs_api, id=job_id)
@pytest.mark.usefixtures("restore_db_per_function")
class TestTokenTracking:
def test_can_update_last_use(self, access_tokens_by_username):
_, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
with make_api_client(access_token=token["private_key"]) as api_client:
updated_token, _ = api_client.auth_api.retrieve_access_tokens_self()
old_last_used_date = token["last_used_date"]
if old_last_used_date is not None:
old_last_used_date = datetime.fromisoformat(
token["last_used_date"].rstrip("Z")
).replace(tzinfo=timezone.utc)
assert updated_token.last_used_date != old_last_used_date
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
def test_can_record_token_events_in_audit_logs(self, admin_user, tasks):
test_start_date = datetime.now(tz=timezone.utc)
with make_api_client(admin_user) as api_client:
token = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token", read_only=False
)
)[0]
task_id = next(p["id"] for p in tasks if p["size"] > 0)
with make_api_client(access_token=token.value) as api_client:
api_client.tasks_api.partial_update(
task_id, patched_task_write_request=models.PatchedTaskWriteRequest(name="newname")
)
with make_api_client(admin_user) as api_client:
api_client.auth_api.partial_update_access_tokens(
token.id,
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
expiry_date=test_start_date + timedelta(days=1)
),
)
api_client.auth_api.destroy_access_tokens(token.id)
# Clickhouse updates are not immediate, vector has buffering on sinks.
# All these checks are in a single test because of this extra waiting.
# Potentially unstable, should probably be improved or maybe moved into server tests.
sleep(5)
events_csv = export_events(api_client, api_version=2, _from=test_start_date)
csv_reader = csv.DictReader(StringIO(events_csv.decode()))
rows = list(csv_reader)
def find_row(scope: str):
return next((i, r) for i, r in enumerate(rows) if r["scope"] == scope)
token_creation_row_index, row = find_row(scope="create:accesstoken")
assert row["obj_id"] == str(token.id)
task_update_row_index, row = find_row(scope="update:task")
assert row["task_id"] == str(task_id)
assert row["access_token_id"] == str(token.id)
assert row["obj_name"] == "name"
assert row["obj_val"] == "newname"
token_update_row_index, row = find_row(scope="update:accesstoken")
# token id is not present in the "update:*" event fields, can't check it
assert row["obj_name"] == "expiry_date"
token_delete_row_index, row = find_row(scope="delete:accesstoken")
assert row["obj_id"] == str(token.id)
assert token_creation_row_index < task_update_row_index
assert task_update_row_index < token_update_row_index
assert token_update_row_index < token_delete_row_index
+367
View File
@@ -0,0 +1,367 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import csv
import json
import uuid
from collections import Counter
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from io import StringIO
from time import sleep
import pytest
from dateutil import parser as datetime_parser
import shared.utils.s3 as s3
from shared.utils.config import delete_method, get_method, make_api_client, server_get
from shared.utils.helpers import generate_image_files
from .utils import create_task, export_events
class TestGetAnalytics:
endpoint = "analytics"
def _test_can_see(self, user):
response = server_get(user, self.endpoint)
assert response.status_code == HTTPStatus.OK
def _test_cannot_see(self, user):
response = server_get(user, self.endpoint)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize(
"conditions, is_allow",
[
(dict(privilege="admin"), True),
(dict(privilege="worker", has_analytics_access=False), False),
(dict(privilege="worker", has_analytics_access=True), True),
(dict(privilege="user", has_analytics_access=False), False),
(dict(privilege="user", has_analytics_access=True), True),
],
)
def test_can_see(self, conditions, is_allow, find_users):
user = find_users(**conditions)[0]["username"]
if is_allow:
self._test_can_see(user)
else:
self._test_cannot_see(user)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetAuditEvents:
_USERNAME = "admin1"
@staticmethod
def _create_project(user, spec, **kwargs):
with make_api_client(user) as api_client:
project, response = api_client.projects_api.create(spec, **kwargs)
assert response.status == HTTPStatus.CREATED
return project.id, response.headers.get("X-Request-Id")
@pytest.fixture(autouse=True)
def setup(self, restore_clickhouse_db_per_function, restore_redis_inmem_per_function):
project_spec = {
"name": f"Test project created by {self._USERNAME}",
"labels": [
{
"name": "car",
"color": "#ff00ff",
"attributes": [
{
"name": "a",
"mutable": True,
"input_type": "number",
"default_value": "5",
"values": ["4", "5", "6"],
}
],
}
],
}
self.project_id, project_request_id = TestGetAuditEvents._create_project(
self._USERNAME, project_spec
)
task_spec = {
"name": f"test {self._USERNAME} to create a task",
"segment_size": 2,
"project_id": self.project_id,
}
task_ids = [
create_task(
self._USERNAME,
task_spec,
{
"image_quality": 10,
"client_files": generate_image_files(3),
},
),
create_task(
self._USERNAME,
task_spec,
{
"image_quality": 10,
"client_files": generate_image_files(3),
},
),
]
self.task_ids = [t[0] for t in task_ids]
assert project_request_id is not None
assert all(t[1] is not None for t in task_ids)
# Events are pushed to ClickHouse asynchronously,
# so we must wait until the expected number of events for every scope is present.
# Items: (filters, expected_count)
event_filters = [
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [project_request_id]),
("scope", ["create:project"]),
),
1,
),
]
for task_id, task_request_id in task_ids:
event_filters.extend(
(
(
(
(
lambda e: json.loads(e["payload"])["request"]["id"],
[task_request_id],
),
("scope", ["create:task"]),
),
1,
),
(
(
("task_id", [str(task_id)]),
("scope", ["create:job"]),
),
2,
),
)
)
self._wait_for_request_ids(event_filters)
def _wait_for_request_ids(self, event_filters):
MAX_RETRIES = 5
SLEEP_INTERVAL = 2
while MAX_RETRIES > 0:
data = self._test_get_audit_logs_as_csv()
events = self._csv_to_dict(data)
if all(
len(self._filter_events(events, filters)) >= expected_count
for filters, expected_count in event_filters
):
break
MAX_RETRIES -= 1
sleep(SLEEP_INTERVAL)
else:
assert False, "Could not wait for expected request IDs"
@staticmethod
def _csv_to_dict(csv_data):
res = []
with StringIO(csv_data.decode()) as f:
reader = csv.DictReader(f)
for row in reader:
res.append(row)
return res
@staticmethod
def _filter_events(events, filters):
res = []
get_value = lambda getter, e: getter(e) if callable(getter) else e.get(getter, None)
for e in events:
if all(get_value(getter, e) in expected_values for getter, expected_values in filters):
res.append(e)
return res
def _test_get_audit_logs_as_csv(self, *, api_version: int = 2, **kwargs):
with make_api_client(self._USERNAME) as api_client:
return export_events(api_client, api_version=api_version, **kwargs)
@pytest.mark.parametrize("api_version", [1, 2])
def test_entry_to_time_interval(self, api_version: int):
now = datetime.now(timezone.utc)
to_datetime = now
from_datetime = now - timedelta(minutes=3)
query_params = {
"_from": from_datetime.isoformat(),
"to": to_datetime.isoformat(),
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
assert len(events)
for event in events:
event_timestamp = datetime_parser.isoparse(event["timestamp"])
assert from_datetime <= event_timestamp <= to_datetime
@pytest.mark.parametrize("api_version", [1, 2])
def test_filter_by_project(self, api_version: int):
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
filtered_events = self._filter_events(events, [("project_id", [str(self.project_id)])])
assert len(filtered_events)
assert len(events) == len(filtered_events)
event_count = Counter([e["scope"] for e in filtered_events])
assert event_count["create:project"] == 1
assert event_count["create:task"] == 2
assert event_count["create:job"] == 4
@pytest.mark.parametrize("api_version", [1, 2])
def test_filter_by_task(self, api_version: int):
for task_id in self.task_ids:
query_params = {
"task_id": task_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
filtered_events = self._filter_events(events, [("task_id", [str(task_id)])])
assert len(filtered_events)
assert len(events) == len(filtered_events)
event_count = Counter([e["scope"] for e in filtered_events])
assert event_count["create:task"] == 1
assert event_count["create:job"] == 2
@pytest.mark.parametrize("api_version", [1, 2])
def test_filter_by_non_existent_project(self, api_version: int):
query_params = {
"project_id": self.project_id + 100,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
assert len(events) == 0
@pytest.mark.parametrize("api_version", [1, 2])
def test_user_and_request_id_not_empty(self, api_version: int):
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
for event in events:
assert event["user_id"]
assert event["user_name"]
assert event["user_email"]
payload = json.loads(event["payload"])
request_id = payload["request"]["id"]
assert request_id
uuid.UUID(request_id)
@pytest.mark.parametrize("api_version", [1, 2])
def test_exported_events_do_not_contain_remote_addr(self, api_version: int):
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
assert len(events)
for event in events:
assert "remote_addr" not in event
@pytest.mark.parametrize("api_version", [1, 2])
def test_delete_project(self, api_version: int):
response = delete_method("admin1", f"projects/{self.project_id}")
assert response.status_code == HTTPStatus.NO_CONTENT
request_id = response.headers.get("X-Request-Id")
# Items: (filters, expected_count). The cascade delete emits one event per
# affected object under the same request id, so wait for all of them.
event_filters = (
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [request_id]),
("scope", ["delete:project"]),
),
1,
),
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [request_id]),
("scope", ["delete:task"]),
),
2,
),
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [request_id]),
("scope", ["delete:job"]),
),
4,
),
)
self._wait_for_request_ids(event_filters)
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
filtered_events = self._filter_events(events, [("project_id", [str(self.project_id)])])
assert len(filtered_events)
assert len(events) == len(filtered_events)
event_count = Counter([e["scope"] for e in filtered_events])
assert event_count["delete:project"] == 1
assert event_count["delete:task"] == 2
assert event_count["delete:job"] == 4
@pytest.mark.with_external_services
@pytest.mark.parametrize("api_version, allowed", [(1, False), (2, True)])
@pytest.mark.parametrize("cloud_storage_id", [3]) # import/export bucket
def test_export_to_cloud(
self, api_version: int, allowed: bool, cloud_storage_id: int, cloud_storages
):
query_params = {
"api_version": api_version,
"location": "cloud_storage",
"cloud_storage_id": cloud_storage_id,
"filename": "test.csv",
"task_id": self.task_ids[0],
}
if allowed:
data = self._test_get_audit_logs_as_csv(**query_params)
assert data is None
s3_client = s3.make_client(bucket=cloud_storages[cloud_storage_id]["resource"])
data = s3_client.download_fileobj(query_params["filename"])
events = self._csv_to_dict(data)
assert len(events)
else:
response = get_method(self._USERNAME, "events", **query_params)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert (
response.json()[0]
== "This endpoint does not support exporting events to cloud storage"
)
+395
View File
@@ -0,0 +1,395 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import math
from collections.abc import Generator
from itertools import product
from pathlib import Path, PurePosixPath
import pytest
from cvat_sdk import exceptions, models
from cvat_sdk.core.exceptions import BackgroundRequestException
from cvat_sdk.core.proxies.tasks import ResourceType, Task
from PIL import Image
from pytest_cases import fixture, fixture_ref, parametrize
import shared.utils.s3 as s3
from shared.utils.config import (
SHARE_DIR,
make_sdk_client,
)
from shared.utils.helpers import read_audio_pcm
from ._test_base import TestTasksBase
@fixture(scope="session")
def fxt_local_audio_with_cover() -> Generator[tuple[Path, Path], None, None]:
# Generate with:
# ffmpeg \
# -i "audio.mp3" \
# -i "cover.png" \
# -map 0:0 \
# -map 1:0 \
# -c copy \
# -id3v2_version 3 \
# "audio_with_cover.mp3"
yield (
SHARE_DIR / "audio" / "sample2_with_cover.mp3",
SHARE_DIR / "audio" / "sample2_cover.png",
)
@pytest.mark.usefixtures("restore_redis_ondisk_after_class")
@pytest.mark.usefixtures("restore_redis_ondisk_per_class")
@pytest.mark.usefixtures("restore_db_per_class")
@pytest.mark.usefixtures("restore_cvat_data_per_class")
class TestAudioTasks:
@pytest.fixture(autouse=True)
def setup(
self,
restore_redis_inmem_per_function,
tmp_path: Path,
admin_user: str,
):
self.tmp_dir = tmp_path
self.user = admin_user
with make_sdk_client(self.user) as client:
self.client = client
yield
@fixture(scope="class")
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")], scope="session")
def fxt_audio_task_from_uploaded_data(
cls, request: pytest.FixtureRequest, admin_user, source_filename: Path
):
with make_sdk_client(admin_user) as client:
yield client.tasks.create_from_data(
spec={
"name": f"{request.node.name}[{request.fixturename}]",
},
resources=[source_filename],
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_create_audio_task_from_local_data(self, task: Task):
assert task.media_type == "audio"
assert task.dimension == "1d"
assert task.mode == "interpolation"
assert task.size > 0
@pytest.mark.with_external_services
@parametrize("use_cache", [True, False])
@parametrize(
"cloud_storage_id",
[
1, # public bucket
2, # private bucket
],
)
def test_can_create_audio_task_from_cloud_data(
self,
fxt_test_name: str,
fxt_uploaded_s3_file,
fxt_local_audio_file_path: Path,
use_cache: bool,
cloud_storage_id: int,
cloud_storages,
organizations,
):
cloud_storage = cloud_storages[cloud_storage_id]
org_id = cloud_storages[cloud_storage_id]["organization"]
org_slug = organizations[org_id]["slug"] if org_id else None
storage_media_path = PurePosixPath(fxt_local_audio_file_path.name)
s3_client = s3.make_client(bucket=cloud_storage["resource"])
fxt_uploaded_s3_file(
s3_client, path=storage_media_path, data=fxt_local_audio_file_path.read_bytes()
)
with self.client.organization_context(org_slug):
task = self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
},
resources=[storage_media_path],
resource_type=ResourceType.SHARE,
data_params={
"cloud_storage_id": cloud_storage_id,
"use_cache": use_cache,
},
)
assert task.media_type == "audio"
assert task.dimension == "1d"
assert task.mode == "interpolation"
assert task.size > 0
assert task.data_cloud_storage_id == cloud_storage_id
@parametrize(
"source_path, cover_image_path",
[
(fixture_ref("fxt_local_audio_file_path"), None),
fixture_ref(fxt_local_audio_with_cover),
],
)
def test_can_use_cover_image_for_preview(self, fxt_test_name, source_path, cover_image_path):
task = self.client.tasks.create_from_data(
spec={"name": fxt_test_name},
resources=[source_path],
)
jobs = task.get_jobs()
for instance in (task, *jobs):
actual = Image.open(instance.get_preview())
assert actual.size > (0, 0)
if cover_image_path is None:
assert actual.format == "PNG" # should return the default image
continue
expected = Image.open(cover_image_path)
expected.thumbnail((256, 256))
TestTasksBase._compare_images(expected, actual, must_be_identical=False)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_split_into_jobs(self, task: Task):
# Only 1 job is allowed for audio data
assert task.jobs.count == 1
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")])
def test_cant_use_segment_size(self, source_filename: Path, fxt_test_name: str):
with pytest.raises(BackgroundRequestException) as capture:
self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
"segment_size": 10000,
},
resources=[source_filename],
)
assert "'segment_size' parameter cannot be used in audio tasks" in str(capture.value)
@pytest.mark.timeout(
# This test has to check all the task chunks availability, it can make many requests
timeout=300
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_get_task_chunks(self, task: Task):
data_meta = task.get_meta()
assert task.data_chunk_size == task.size
assert task.data_compressed_chunk_type == "audio_mp3"
assert task.data_original_chunk_type == "audio_mp3"
for quality, chunk_id in product(
["original", "compressed"],
range(math.ceil((data_meta.stop_frame - data_meta.start_frame) / data_meta.chunk_size)),
):
response = task.api.retrieve_data(
task.id, type="chunk", quality=quality, number=chunk_id, _parse_response=False
)[1]
chunk_file = io.BytesIO(response.data)
chunk_audio, sampling_rate = read_audio_pcm(chunk_file)
assert chunk_audio.shape[0] / sampling_rate >= data_meta.size / 1000
@pytest.mark.timeout(
# This test has to check all the job chunks availability, it can make many requests
timeout=300
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
@parametrize("indexing", ["absolute", "relative"])
def test_can_get_job_chunks(self, task: Task, indexing: str):
jobs = sorted(task.get_jobs(), key=lambda j: j.start_frame)
assert len(jobs) == 1 # only 1 job is allowed per task so far
for job in jobs:
job_meta = job.get_meta()
assert job.data_chunk_size == job.frame_count
assert job.data_compressed_chunk_type == "audio_mp3"
assert job.data_original_chunk_type == "audio_mp3"
for quality, chunk_id in product(
["original", "compressed"],
range(math.ceil((job_meta.stop_frame - job_meta.start_frame) / job_meta.chunk_size)),
):
response = job.api.retrieve_data(
job.id,
type="chunk",
quality=quality,
**({"number": chunk_id} if indexing == "absolute" else {"index": chunk_id}),
_parse_response=False,
)[1]
chunk_file = io.BytesIO(response.data)
chunk_audio, sampling_rate = read_audio_pcm(chunk_file)
assert chunk_audio.shape[0] / sampling_rate >= job_meta.size / 1000
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_create_gt_job(self, task: Task):
gt_job = self.client.jobs.api.create(
job_write_request=models.JobWriteRequest(type="ground_truth", task_id=task.id)
)[0]
assert gt_job.type == "ground_truth"
assert gt_job.frame_count == task.size
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")])
def test_can_create_task_with_gt_job(self, fxt_test_name: str, source_filename: Path):
task = self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
},
resources=[source_filename],
data_params={"validation_params": {"mode": "gt"}},
)
gt_job = next(j for j in task.get_jobs() if j.type == "ground_truth")
assert gt_job.type == "ground_truth"
assert gt_job.frame_count == task.size
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_cant_export_dataset(self, task: Task):
with pytest.raises(BackgroundRequestException) as capture:
task.export_dataset("Generic TSV 1.0", filename=self.tmp_dir, include_images=True)
assert "export as dataset is not supported for audio" in str(capture.value)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_cant_import_dataset(self, task: Task, fxt_test_name: str):
project = self.client.projects.create({"name": fxt_test_name})
temp_file = self.tmp_dir / "test.tsv"
temp_file.write_text("test")
with pytest.raises(BackgroundRequestException) as capture:
project.import_dataset("Generic TSV 1.0", filename=temp_file)
assert "import from dataset is not supported for audio" in str(capture.value)
class TestAudioAnnotations:
@pytest.fixture(autouse=True)
def setup(
self,
restore_db_per_function,
admin_user: str,
):
self.user = admin_user
with make_sdk_client(self.user) as client:
self.client = client
yield
@parametrize("instance_type", ["task", "job"])
def test_can_save_intervals(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
task.update({"labels": [{"name": "test", "type": "interval"}]})
label = task.get_labels()[-1]
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=label.id,
start=0,
stop=task.size - 1,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
instance.set_annotations(payload)
server_annotations = instance.get_annotations()
assert len(server_annotations.intervals) == 1
assert server_annotations.intervals[0].label_id == payload.intervals[0].label_id
assert server_annotations.intervals[0].start == payload.intervals[0].start
assert server_annotations.intervals[0].stop == payload.intervals[0].stop
@parametrize("instance_type", ["task", "job"])
def test_can_save_interval_with_null_stop(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
task.update({"labels": [{"name": "test", "type": "interval"}]})
label = task.get_labels()[-1]
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=label.id,
start=0,
stop=None,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
instance.set_annotations(payload)
server_annotations = instance.get_annotations()
assert len(server_annotations.intervals) == 1
assert server_annotations.intervals[0].label_id == payload.intervals[0].label_id
assert server_annotations.intervals[0].start == payload.intervals[0].start
assert server_annotations.intervals[0].stop is None
@parametrize("instance_type", ["task", "job"])
def test_cant_save_intervals_outside_range(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
labels = task.get_labels()
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=labels[0].id,
start=0,
stop=task.size,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
with pytest.raises(exceptions.ApiException) as capture:
instance.set_annotations(payload)
assert "cannot be outside" in str(capture.value)
+327
View File
@@ -0,0 +1,327 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from collections.abc import Generator
from contextlib import contextmanager
from http import HTTPStatus
from unittest import mock
import pytest
from cvat_sdk.api_client import ApiClient, Configuration, models
from shared.utils.config import BASE_URL, USER_PASS, make_api_client
@pytest.mark.usefixtures("restore_db_per_class")
class TestBasicAuth:
def test_can_use_basic_auth(self, admin_user: str):
username = admin_user
config = Configuration(host=BASE_URL, username=username, password=USER_PASS)
with ApiClient(config) as client:
user, response = client.users_api.retrieve_self()
assert response.status == HTTPStatus.OK
assert user.username == username
@pytest.mark.usefixtures("restore_db_per_function")
class TestTokenAuth:
@staticmethod
def login(api_client: ApiClient, username: str) -> models.Token:
auth, _ = api_client.auth_api.create_login(
models.LoginSerializerExRequest(username=username, password=USER_PASS)
)
# Remove automatically managed cookies
api_client.cookies.pop("sessionid")
api_client.cookies.pop("csrftoken")
# Set up the token authentication
api_client.set_default_header("Authorization", "Token " + auth.key)
return auth
@classmethod
@contextmanager
def make_client(cls, username: str | None = None) -> Generator[ApiClient, None, None]:
with ApiClient(Configuration(host=BASE_URL)) as api_client:
if username:
cls.login(api_client, username)
yield api_client
def _test_can_auth(self, api_client: ApiClient, *, username: str, auth_key: str):
from cvat_sdk.api_client.rest import RESTClientObject
original_request = RESTClientObject.request
def patched_request(*args, **kwargs):
assert "sessionid" not in kwargs["headers"].get("Cookie", "")
assert "X-CSRFToken" not in kwargs["headers"]
assert kwargs["headers"]["Authorization"] == "Token " + auth_key
return original_request(api_client.rest_client, *args, **kwargs)
with mock.patch.object(
api_client.rest_client, "request", side_effect=patched_request
) as mock_request:
user, response = api_client.users_api.retrieve_self()
mock_request.assert_called_once()
assert response.status == HTTPStatus.OK
assert user.username == username
def test_can_use_token_auth(self, admin_user: str):
username = admin_user
with self.make_client() as api_client:
auth = self.login(api_client, username=username)
assert auth.key
self._test_can_auth(api_client, username=username, auth_key=auth.key)
def test_can_use_token_auth_from_config(self, admin_user: str):
username = admin_user
with self.make_client() as api_client:
auth = self.login(api_client, username=username)
config = Configuration(
host=BASE_URL,
api_key={
"tokenAuth": auth.key,
},
)
with ApiClient(config) as api_client:
self._test_can_auth(api_client, username=username, auth_key=auth.key)
def test_can_logout(self, admin_user: str):
with self.make_client(admin_user) as api_client:
_, response = api_client.auth_api.create_logout()
assert response.status == HTTPStatus.OK
_, response = api_client.users_api.retrieve_self(
_parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("restore_db_per_function")
class TestSessionAuth:
@staticmethod
def login(api_client: ApiClient, username: str) -> models.Token:
auth, _ = api_client.auth_api.create_login(
models.LoginSerializerExRequest(username=username, password=USER_PASS)
)
# Set up the session and CSRF authentication
api_client.set_default_header("Origin", api_client.build_origin_header())
api_client.set_default_header("X-CSRFToken", api_client.cookies["csrftoken"].value)
return auth
@classmethod
@contextmanager
def make_client(cls, username: str | None = None) -> Generator[ApiClient, None, None]:
with ApiClient(Configuration(host=BASE_URL)) as api_client:
if username:
cls.login(api_client, username)
yield api_client
def _test_can_use_auth(self, api_client: ApiClient, *, username: str):
from cvat_sdk.api_client.rest import RESTClientObject
original_request = RESTClientObject.request
def patched_request(*args, **kwargs):
assert "sessionid" in kwargs["headers"].get("Cookie", "")
assert "csrftoken" in kwargs["headers"].get("Cookie", "")
assert "X-CSRFToken" in kwargs["headers"]
assert "Origin" in kwargs["headers"]
assert "Authorization" not in kwargs["headers"]
return original_request(api_client.rest_client, *args, **kwargs)
with mock.patch.object(
api_client.rest_client, "request", side_effect=patched_request
) as mock_request:
user, response = api_client.users_api.retrieve_self()
mock_request.assert_called_once()
assert response.status == HTTPStatus.OK
assert user.username == username
# Check CSRF authentication in an "unsafe" request
api_client.users_api.partial_update(
user.id, patched_user_request=models.PatchedUserRequest(first_name="Newname")
)
def test_can_use_session_auth(self, admin_user: str):
username = admin_user
with self.make_client(username) as api_client:
self._test_can_use_auth(api_client, username=username)
def test_can_use_session_auth_from_config(self, admin_user: str):
username = admin_user
with self.make_client(username) as api_client:
config = Configuration(
host=BASE_URL,
api_key={
"sessionAuth": api_client.cookies["sessionid"].value,
"csrfAuth": api_client.cookies["csrftoken"].value,
},
)
with ApiClient(config) as api_client:
self._test_can_use_auth(api_client, username=username)
def test_can_logout(self, admin_user: str):
with self.make_client(admin_user) as api_client:
_, response = api_client.auth_api.create_logout()
assert response.status == HTTPStatus.OK
_, response = api_client.users_api.retrieve_self(
_parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("restore_db_per_function")
class TestAccessTokenAuth:
@classmethod
@contextmanager
def make_client(cls, *, token: str | None = None) -> Generator[ApiClient, None, None]:
with ApiClient(Configuration(host=BASE_URL)) as api_client:
if token:
api_client.configuration.access_token = token
yield api_client
def _test_can_use_auth(self, api_client: ApiClient, *, username: str, access_token: str):
from cvat_sdk.api_client.rest import RESTClientObject
original_request = RESTClientObject.request
def patched_request(*args, **kwargs):
assert "sessionid" not in kwargs["headers"].get("Cookie", "")
assert "X-CSRFToken" not in kwargs["headers"]
assert kwargs["headers"]["Authorization"] == "Bearer " + access_token
return original_request(api_client.rest_client, *args, **kwargs)
with mock.patch.object(
api_client.rest_client, "request", side_effect=patched_request
) as mock_request:
user, response = api_client.users_api.retrieve_self()
mock_request.assert_called_once()
assert response.status == HTTPStatus.OK
assert user.username == username
def test_can_use_token_auth(self, admin_user: str, access_tokens_by_username):
token = access_tokens_by_username[admin_user][0]["private_key"]
with self.make_client(token=token) as api_client:
self._test_can_use_auth(api_client, username=admin_user, access_token=token)
def test_logout_is_not_an_error(self, admin_user: str, access_tokens_by_username):
token = access_tokens_by_username[admin_user][0]["private_key"]
with ApiClient(Configuration(host=BASE_URL)) as session_api_client:
session_api_client.auth_api.create_login(
login_serializer_ex_request=models.LoginSerializerExRequest(
username=admin_user, password=USER_PASS
)
)
session_api_client.set_default_header(
"Origin", session_api_client.build_origin_header()
)
session_api_client.set_default_header(
"X-CSRFToken", session_api_client.cookies["csrftoken"].value
)
with self.make_client(token=token) as token_api_client:
# It must be a noop call in the case of API access token auth
_, response = token_api_client.auth_api.create_logout()
assert response.status == HTTPStatus.OK
# the credentials are still in the client and can be used
assert token_api_client.configuration.access_token == token
self._test_can_use_auth(token_api_client, username=admin_user, access_token=token)
# Other sessions must not be affected by the logout
session_api_client.users_api.retrieve_self()
@pytest.mark.usefixtures("restore_db_per_function")
class TestCredentialsManagement:
def test_can_register(self):
username = "newuser"
email = "123@456.com"
with ApiClient(Configuration(host=BASE_URL)) as api_client:
user, response = api_client.auth_api.create_register(
models.RegisterSerializerExRequest(
username=username, password1=USER_PASS, password2=USER_PASS, email=email
)
)
assert response.status == HTTPStatus.CREATED
assert user.username == username
with make_api_client(username) as api_client:
user, response = api_client.users_api.retrieve_self()
assert response.status == HTTPStatus.OK
assert user.username == username
assert user.email == email
def test_can_change_password(self, admin_user: str):
username = admin_user
new_pass = "5w4knrqaW#$@gewa"
with make_api_client(username) as api_client:
info, response = api_client.auth_api.create_password_change(
models.PasswordChangeRequest(
old_password=USER_PASS, new_password1=new_pass, new_password2=new_pass
)
)
assert response.status == HTTPStatus.OK
assert info.detail == "New password has been saved."
_, response = api_client.users_api.retrieve_self(
_parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.UNAUTHORIZED
api_client.configuration.password = new_pass
user, response = api_client.users_api.retrieve_self()
assert response.status == HTTPStatus.OK
assert user.username == username
def test_can_report_weak_password(self, admin_user: str):
username = admin_user
new_pass = "password"
with make_api_client(username) as api_client:
_, response = api_client.auth_api.create_password_change(
models.PasswordChangeRequest(
old_password=USER_PASS, new_password1=new_pass, new_password2=new_pass
),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.BAD_REQUEST
assert json.loads(response.data) == {"new_password2": ["This password is too common."]}
def test_can_report_mismatching_passwords(self, admin_user: str):
username = admin_user
with make_api_client(username) as api_client:
_, response = api_client.auth_api.create_password_change(
models.PasswordChangeRequest(
old_password=USER_PASS, new_password1="3j4tb13/T$#", new_password2="q#@$n34g5"
),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.BAD_REQUEST
assert json.loads(response.data) == {
"new_password2": ["The two password fields didnt match."]
}
@@ -0,0 +1,42 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import re
from http import HTTPStatus
from shared.utils.config import server_get
class TestCachePolicy:
@staticmethod
def _get_js_bundle_url(response):
match = re.search(r'<script.* src="(/assets/cvat-ui.\w+.min.js)".*></script>', response)
if match:
return match.group(1)
def _test_cache_policy_enabled(self, response):
assert response.status_code == HTTPStatus.OK
assert (
"public" in response.headers["Cache-Control"]
and "max-age" in response.headers["Cache-Control"]
)
def _test_cache_policy_disabled(self, response):
assert response.status_code == HTTPStatus.OK
assert "no-cache" in response.headers["Cache-Control"]
def test_index_not_cached(self, find_users):
user = find_users(privilege="user")[0]["username"]
index_page_response = server_get(user, "/")
self._test_cache_policy_disabled(index_page_response)
def test_asset_cached(self, find_users):
user = find_users(privilege="user")[0]["username"]
index_page_response = server_get(user, "/")
js_asset_url = self._get_js_bundle_url(index_page_response.content.decode("utf-8"))
js_asset_response = server_get(user, js_asset_url)
self._test_cache_policy_enabled(js_asset_response)
@@ -0,0 +1,73 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from pathlib import Path
import pytest
from deepdiff import DeepDiff
from shared.utils import config
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetResources:
@pytest.mark.parametrize("path", config.ASSETS_DIR.glob("*.json"))
def test_check_objects_integrity(self, path: Path):
with open(path) as f:
endpoint = path.stem
if endpoint in [
"quality_settings",
"quality_reports",
"quality_conflicts",
"consensus_settings",
]:
endpoint = "/".join(endpoint.split("_"))
elif endpoint == "access_tokens":
endpoint = "auth/access_tokens"
if endpoint == "annotations":
objects = json.load(f)
for jid, annotations in objects["job"].items():
response = config.get_method("admin1", f"jobs/{jid}/annotations").json()
assert (
DeepDiff(
annotations,
response,
ignore_order=True,
exclude_paths="root['version']",
)
== {}
)
elif endpoint == "auth/access_tokens":
objects = json.load(f)
assert set(objects) == {"user"}
for username, tokens in objects["user"].items():
response = config.get_method(
username, "auth/access_tokens", page_size=100, sort="id"
).json()["results"]
assert (
DeepDiff(
tokens,
response,
ignore_order=True,
)
== {}
)
else:
response = config.get_method("admin1", endpoint, page_size="all")
json_objs = json.load(f)
resp_objs = response.json()
assert (
DeepDiff(
json_objs,
resp_objs,
ignore_order=True,
exclude_regex_paths=r"root\['results'\]\[\d+\]\['last_login'\]",
)
== {}
)
@@ -0,0 +1,767 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import json
from functools import partial
from http import HTTPStatus
from typing import Any
import pytest
from cvat_sdk.api_client import ApiClient, models
from cvat_sdk.api_client.api_client import Endpoint
from cvat_sdk.api_client.model.file_info import FileInfo
from deepdiff import DeepDiff
from PIL import Image
from shared.utils.config import get_method, make_api_client
from shared.utils.s3 import make_client as make_s3_client
from .utils import CollectionSimpleFilterTestBase
# https://docs.pytest.org/en/7.1.x/example/markers.html#marking-whole-classes-or-modules
pytestmark = [pytest.mark.with_external_services]
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetCloudStorage:
def _test_can_see(self, user, storage_id, data):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.OK
response_json = json.loads(response.data)
assert (
DeepDiff(
data, response_json, ignore_order=True, exclude_paths="root['updated_date']"
)
== {}
)
def _test_cannot_see(self, user, storage_id):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("storage_id", [1])
@pytest.mark.parametrize(
"group, is_owner, is_allow",
[
("admin", False, True),
("user", True, True),
],
)
def test_sandbox_user_get_cloud_storage(
self, storage_id, group, is_owner, is_allow, users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u for u in users if group in u["groups"] and u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id, cloud_storage)
else:
self._test_cannot_see(username, storage_id)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize("storage_id", [2])
@pytest.mark.parametrize(
"role, is_owner, is_allow",
[
("worker", True, True),
("supervisor", False, True),
("worker", False, False),
],
)
def test_org_user_get_cloud_storage(
self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u
for u in find_users(role=role, org=org_id)
if u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id, cloud_storage)
else:
self._test_cannot_see(username, storage_id)
def test_can_remove_owner_and_fetch_with_sdk(self, admin_user, cloud_storages):
# test for API schema regressions
source_storage = next(
s for s in cloud_storages if s.get("owner") and s["owner"]["username"] != admin_user
).copy()
with make_api_client(admin_user) as api_client:
api_client.users_api.destroy(source_storage["owner"]["id"])
_, response = api_client.cloudstorages_api.retrieve(source_storage["id"])
fetched_storage = json.loads(response.data)
source_storage["owner"] = None
assert DeepDiff(source_storage, fetched_storage, ignore_order=True) == {}
class TestCloudStoragesListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"name": ["display_name"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, cloud_storages):
self.user = admin_user
self.samples = cloud_storages
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.cloudstorages_api.list_endpoint
@pytest.mark.parametrize(
"field",
("provider_type", "name", "resource", "credentials_type", "owner"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPostCloudStorage:
_SPEC = {
"provider_type": "AWS_S3_BUCKET",
"resource": "test",
"display_name": "Bucket",
"credentials_type": "KEY_SECRET_KEY_PAIR",
"key": "minio_access_key",
"secret_key": "minio_secret_key",
"specific_attributes": "endpoint_url=http://minio:9000",
"description": "Some description",
"manifests": ["images_with_manifest/manifest.jsonl"],
}
_EXCLUDE_PATHS = [
f"root['{extra_field}']"
for extra_field in {
# unchanged fields
"created_date",
"id",
"organization",
"owner",
"updated_date",
# credentials that server doesn't return
"key",
"secret_key",
}
]
def _test_can_create(self, user, spec, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.create(
models.CloudStorageWriteRequest(**spec),
**kwargs,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.CREATED
response_json = json.loads(response.data)
assert (
DeepDiff(
self._SPEC, response_json, ignore_order=True, exclude_paths=self._EXCLUDE_PATHS
)
== {}
)
def _test_cannot_create(self, user, spec, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.create(
models.CloudStorageWriteRequest(**spec),
**kwargs,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("group, is_allow", [("user", True), ("worker", False)])
def test_sandbox_user_create_cloud_storage(self, group, is_allow, users):
org = ""
username = [u for u in users if group in u["groups"]][0]["username"]
if is_allow:
self._test_can_create(username, self._SPEC, org=org)
else:
self._test_cannot_create(username, self._SPEC, org=org)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize(
"role, is_allow",
[
("owner", True),
("maintainer", True),
("worker", False),
("supervisor", False),
],
)
def test_org_user_create_cloud_storage(self, org_id, role, is_allow, find_users):
username = find_users(role=role, org=org_id)[0]["username"]
if is_allow:
self._test_can_create(username, self._SPEC, org_id=org_id)
else:
self._test_cannot_create(username, self._SPEC, org_id=org_id)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchCloudStorage:
_SPEC = {
"display_name": "New display name",
"description": "New description",
"manifests": [
"images_with_manifest/manifest_1.jsonl",
"images_with_manifest/manifest_2.jsonl",
],
}
_PRIVATE_BUCKET_SPEC = {
"display_name": "New display name",
"description": "New description",
"manifests": [
"sub/images_with_manifest/manifest_1.jsonl",
"sub/images_with_manifest/manifest_2.jsonl",
],
}
_EXCLUDE_PATHS = [
f"root['{extra_field}']"
for extra_field in {
# unchanged fields
"created_date",
"credentials_type",
"id",
"organization",
"owner",
"provider_type",
"resource",
"specific_attributes",
"updated_date",
}
]
def _test_can_update(self, user, storage_id, spec):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.partial_update(
id=storage_id,
patched_cloud_storage_write_request=models.PatchedCloudStorageWriteRequest(**spec),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.OK
response_json = json.loads(response.data)
assert (
DeepDiff(spec, response_json, ignore_order=True, exclude_paths=self._EXCLUDE_PATHS)
== {}
)
def _test_cannot_update(self, user, storage_id, spec):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.partial_update(
id=storage_id,
patched_cloud_storage_write_request=models.PatchedCloudStorageWriteRequest(**spec),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("storage_id", [1])
@pytest.mark.parametrize(
"group, is_owner, is_allow",
[
("admin", False, True),
("worker", True, True),
],
)
def test_sandbox_user_update_cloud_storage(
self, storage_id, group, is_owner, is_allow, users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u for u in users if group in u["groups"] and u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_update(username, storage_id, self._SPEC)
else:
self._test_cannot_update(username, storage_id, self._SPEC)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize("storage_id", [2])
@pytest.mark.parametrize(
"role, is_owner, is_allow",
[
("worker", True, True),
("maintainer", False, True),
("supervisor", False, False),
],
)
def test_org_user_update_cloud_storage(
self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u
for u in find_users(role=role, org=org_id)
if u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_update(username, storage_id, self._PRIVATE_BUCKET_SPEC)
else:
self._test_cannot_update(username, storage_id, self._PRIVATE_BUCKET_SPEC)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetCloudStoragePreview:
def _test_can_see(self, user, storage_id):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve_preview(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.OK
width, height = Image.open(io.BytesIO(response.data)).size
assert width > 0 and height > 0
def _test_cannot_see(self, user, storage_id):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve_preview(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("storage_id", [1])
@pytest.mark.parametrize(
"group, is_owner, is_allow",
[
("admin", False, True),
("user", True, True),
],
)
def test_sandbox_user_get_cloud_storage_preview(
self, storage_id, group, is_owner, is_allow, users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u for u in users if group in u["groups"] and u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id)
else:
self._test_cannot_see(username, storage_id)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize("storage_id", [2])
@pytest.mark.parametrize(
"role, is_owner, is_allow",
[
("worker", True, True),
("supervisor", False, True),
("worker", False, False),
],
)
def test_org_user_get_cloud_storage_preview(
self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u
for u in find_users(role=role, org=org_id)
if u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id)
else:
self._test_cannot_see(username, storage_id)
@pytest.mark.usefixtures("restore_db_per_function")
class TestGetCloudStorageContent:
USER = "admin1"
def _test_get_cloud_storage_content(
self,
cloud_storage_id: int,
manifest: str | None = None,
**kwargs,
):
with make_api_client(self.USER) as api_client:
content_kwargs = {"manifest_path": manifest} if manifest else {}
for item in ["next_token", "prefix", "page_size"]:
if item_value := kwargs.get(item):
content_kwargs[item] = item_value
data, _ = api_client.cloudstorages_api.retrieve_content_v2(
cloud_storage_id, **content_kwargs
)
return data
@pytest.mark.parametrize("cloud_storage_id", [2])
@pytest.mark.parametrize(
"manifest, prefix, default_bucket_prefix, page_size, expected_content",
[
(
# [v2] list root of bucket with based on manifest
"sub/images_with_manifest/manifest.jsonl",
None,
None,
None,
[FileInfo(mime_type="DIR", name="sub", type="DIR")],
),
(
# [v2] search by some prefix in bucket content based on manifest
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case_65_1",
None,
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list nested directory of bucket content based on manifest
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/",
None,
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
],
),
(
# [v2] list root of real bucket content
None,
None,
None,
None,
[FileInfo(mime_type="DIR", name="sub", type="DIR")],
),
(
# [v2] list nested directory of real bucket content
None,
"sub/images_with_manifest/",
None,
2,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
None,
"/sub/images_with_manifest/", # cover case: API is identical to share point API
None,
None,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
FileInfo(mime_type="unknown", name="manifest.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_1.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_2.jsonl", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix is set to directory
"sub/images_with_manifest/manifest.jsonl",
None,
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix
# is set to template from which the files should start
"sub/images_with_manifest/manifest.jsonl",
None,
"sub/images_with_manifest/image_case_65_1",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when specified prefix is stricter than default bucket prefix
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix is stricter than specified prefix
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case",
"sub/images_with_manifest/image_case_65_1",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix and specified prefix have no intersection
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case_65_2",
None,
[],
),
(
# [v2] list bucket content based on manifest when default bucket prefix contains dirs and prefix starts with it
"sub/images_with_manifest/manifest.jsonl",
"s",
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="DIR", name="sub", type="DIR"),
],
),
(
# [v2] list real bucket content when default bucket prefix is set to directory
None,
None,
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
FileInfo(mime_type="unknown", name="manifest.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_1.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_2.jsonl", type="REG"),
],
),
(
# [v2] list real bucket content when default bucket prefix
# is set to template from which the files should start
None,
None,
"sub/images_with_manifest/demo",
None,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
],
),
(
# [v2] list real bucket content when specified prefix is stricter than default bucket prefix
None,
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list real bucket content when default bucket prefix is stricter than specified prefix
None,
"sub/images_with_manifest/image_case",
"sub/images_with_manifest/image_case_65_1",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list real bucket content when default bucket prefix and specified prefix have no intersection
None,
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case_65_2",
None,
[],
),
(
# [v2] list real bucket content when default bucket prefix contains dirs and prefix starts with it
None,
"s",
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="DIR", name="sub", type="DIR"),
],
),
],
)
def test_get_cloud_storage_content(
self,
cloud_storage_id: int,
manifest: str | None,
prefix: str | None,
default_bucket_prefix: str | None,
page_size: int | None,
expected_content: Any | None,
cloud_storages,
):
if default_bucket_prefix:
cloud_storage = cloud_storages[cloud_storage_id]
with make_api_client(self.USER) as api_client:
_, response = api_client.cloudstorages_api.partial_update(
cloud_storage_id,
patched_cloud_storage_write_request={
"specific_attributes": f'{cloud_storage["specific_attributes"]}&prefix={default_bucket_prefix}'
},
)
assert response.status == HTTPStatus.OK
result = self._test_get_cloud_storage_content(
cloud_storage_id, manifest, prefix=prefix, page_size=page_size
)
if expected_content:
assert result["content"] == expected_content
if page_size:
assert len(result["content"]) <= page_size
@pytest.mark.parametrize("cloud_storage_id, prefix, page_size", [(2, "sub/", 2)])
def test_iterate_over_cloud_storage_content(
self, cloud_storage_id: int, prefix: str, page_size: int
):
expected_content = self._test_get_cloud_storage_content(cloud_storage_id, prefix=prefix)[
"content"
]
current_content = []
next_token = None
while True:
result = self._test_get_cloud_storage_content(
cloud_storage_id,
prefix=prefix,
page_size=page_size,
next_token=next_token,
)
content = result["content"]
assert len(content) <= page_size
current_content.extend(content)
next_token = result["next"]
if not next_token:
break
# Each page is currently sorted individually, so we have to resort after combining.
assert expected_content == sorted(current_content, key=lambda el: el["type"].value)
@pytest.mark.parametrize("cloud_storage_id", [2])
def test_can_get_storage_content_with_manually_created_dirs(
self,
cloud_storage_id: int,
request,
cloud_storages,
):
initial_content = self._test_get_cloud_storage_content(cloud_storage_id)["content"]
cs_name = cloud_storages[cloud_storage_id]["resource"]
s3_client = make_s3_client(bucket=cs_name)
new_directory = "manually_created_directory/"
# directory is 0 size object that has a name ending with a forward slash
s3_client.create_file(
filename=new_directory,
)
request.addfinalizer(
partial(
s3_client.remove_file,
filename=new_directory,
)
)
content = self._test_get_cloud_storage_content(
cloud_storage_id,
)["content"]
assert len(initial_content) + 1 == len(content)
assert any(
new_directory.strip("/") == x["name"] and "DIR" == str(x["type"]) for x in content
)
content = self._test_get_cloud_storage_content(
cloud_storage_id,
prefix=new_directory,
)["content"]
assert not len(content)
@pytest.mark.usefixtures("restore_db_per_class")
class TestListCloudStorages:
def _test_can_see_cloud_storages(self, user, data, **kwargs):
response = get_method(user, "cloudstorages", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def test_admin_can_see_all_cloud_storages(self, cloud_storages):
self._test_can_see_cloud_storages("admin2", cloud_storages.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(2, 2), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, cloud_storages):
cloud_storages = filter(lambda i: i["organization"] == field_value, cloud_storages)
self._test_can_see_cloud_storages(
"admin2", list(cloud_storages), page_size="all", org_id=query_value
)
class TestCloudStorageStatus:
@pytest.mark.parametrize(
"cloud_storage_id, expected_response",
[
(3, "AVAILABLE"),
(4, "NOT_FOUND"),
],
)
def test_minio_connection_status(self, cloud_storage_id, expected_response, admin_user):
with make_api_client(admin_user) as api_client:
data, _ = api_client.cloudstorages_api.retrieve_status(cloud_storage_id)
assert data == expected_response
+734
View File
@@ -0,0 +1,734 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from copy import deepcopy
from functools import partial
from http import HTTPStatus
from itertools import product
from typing import Any
import pytest
import urllib3
from cvat_sdk.api_client import exceptions, models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.core.helpers import get_paginated_collection
from deepdiff import DeepDiff
from shared.utils.config import make_api_client
from .utils import (
CollectionSimpleFilterTestBase,
compare_annotations,
invite_user_to_org,
register_new_user,
wait_background_request,
)
class _PermissionTestBase:
def merge(
self,
*,
task_id: int | None = None,
job_id: int | None = None,
user: str,
raise_on_error: bool = True,
wait_result: bool = True,
) -> urllib3.HTTPResponse:
assert task_id is not None or job_id is not None
kwargs = {}
if task_id is not None:
kwargs["task_id"] = task_id
if job_id is not None:
kwargs["job_id"] = job_id
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.create_merge(
consensus_merge_create_request=models.ConsensusMergeCreateRequest(**kwargs),
_parse_response=False,
_check_status=raise_on_error,
)
if not raise_on_error and response.status != HTTPStatus.ACCEPTED:
return response
assert response.status == HTTPStatus.ACCEPTED
if wait_result:
rq_id = json.loads(response.data)["rq_id"]
background_request, _ = wait_background_request(api_client, rq_id)
assert (
background_request.status.value
== models.RequestStatus.allowed_values[("value",)]["FINISHED"]
)
return response
def request_merge(
self,
*,
task_id: int | None = None,
job_id: int | None = None,
user: str,
) -> str:
response = self.merge(user=user, task_id=task_id, job_id=job_id, wait_result=False)
return json.loads(response.data)["rq_id"]
@pytest.fixture
def find_sandbox_task(self, tasks, jobs, users, is_task_staff):
def _find(
is_staff: bool, *, has_consensus_jobs: bool | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
task = next(
t
for t in tasks
if t["organization"] is None
and not users[t["owner"]["id"]]["is_superuser"]
and (
has_consensus_jobs is None
or has_consensus_jobs
== any(
j
for j in jobs
if j["task_id"] == t["id"] and j["type"] == "consensus_replica"
)
)
)
if is_staff:
user = task["owner"]
else:
user = next(u for u in users if not is_task_staff(u["id"], task["id"]))
return task, user
return _find
@pytest.fixture
def find_sandbox_task_with_consensus(self, find_sandbox_task):
return partial(find_sandbox_task, has_consensus_jobs=True)
@pytest.fixture
def find_org_task(
self, restore_db_per_function, tasks, jobs, users, is_org_member, is_task_staff, admin_user
):
def _find(
is_staff: bool, user_org_role: str, *, has_consensus_jobs: bool | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
for user in users:
if user["is_superuser"]:
continue
task = next(
(
t
for t in tasks
if t["organization"] is not None
and is_task_staff(user["id"], t["id"]) == is_staff
and is_org_member(user["id"], t["organization"], role=user_org_role)
and (
has_consensus_jobs is None
or has_consensus_jobs
== any(
j
for j in jobs
if j["task_id"] == t["id"] and j["type"] == "consensus_replica"
)
)
),
None,
)
if task is not None:
break
if not task:
task = next(
t
for t in tasks
if t["organization"] is not None
if has_consensus_jobs is None or has_consensus_jobs == t["consensus_enabled"]
)
user = next(
u
for u in users
if is_org_member(u["id"], task["organization"], role=user_org_role)
)
if is_staff:
with make_api_client(admin_user) as api_client:
api_client.tasks_api.partial_update(
task["id"],
patched_task_write_request=models.PatchedTaskWriteRequest(
assignee_id=user["id"]
),
)
return task, user
return _find
@pytest.fixture
def find_org_task_with_consensus(self, find_org_task):
return partial(find_org_task, has_consensus_jobs=True)
_default_sandbox_cases = ("is_staff, allow", [(True, True), (False, False)])
_default_org_cases = (
"org_role, is_staff, allow",
[
("owner", True, True),
("owner", False, True),
("maintainer", True, True),
("maintainer", False, True),
("supervisor", True, True),
("supervisor", False, False),
("worker", True, True),
("worker", False, False),
],
)
_default_org_roles = ("owner", "maintainer", "supervisor", "worker")
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestPostConsensusMerge(_PermissionTestBase):
def test_can_merge_task_with_consensus_jobs(self, admin_user, tasks):
task_id = next(t["id"] for t in tasks if t["consensus_enabled"])
self.merge(user=admin_user, task_id=task_id)
def test_can_merge_consensus_job(self, admin_user, jobs):
job_id = next(
j["id"] for j in jobs if j["type"] == "annotation" and j["consensus_replicas"] > 0
)
self.merge(user=admin_user, job_id=job_id)
def test_cannot_merge_task_without_consensus_jobs(self, admin_user, tasks):
task_id = next(t["id"] for t in tasks if not t["consensus_enabled"])
with pytest.raises(exceptions.ApiException) as capture:
self.merge(user=admin_user, task_id=task_id)
assert "Consensus is not enabled in this task" in capture.value.body
def test_cannot_merge_task_without_mergeable_parent_jobs(self, admin_user, tasks, jobs):
task_id = next(t["id"] for t in tasks if t["consensus_enabled"])
for j in jobs:
if (j["stage"] != "annotation" or j["state"] != "new") and (
j["task_id"] == task_id and j["type"] in ("annotation", "consensus_replica")
):
with make_api_client(admin_user) as api_client:
api_client.jobs_api.partial_update(
j["id"],
patched_job_write_request=models.PatchedJobWriteRequest(
state="new", stage="annotation"
),
)
with pytest.raises(exceptions.ApiException) as capture:
self.merge(user=admin_user, task_id=task_id)
assert "No annotation jobs in the annotation stage" in capture.value.body
def test_cannot_merge_replica_job(self, admin_user, tasks, jobs):
job_id = next(
j["id"]
for j in jobs
if j["type"] == "consensus_replica"
if tasks.map[j["task_id"]]["consensus_enabled"]
)
with pytest.raises(exceptions.ApiException) as capture:
self.merge(user=admin_user, job_id=job_id)
assert "No annotated consensus jobs found for parent job" in capture.value.body
def _test_merge_200(self, user: str, *, task_id: int | None = None, job_id: int | None = None):
return self.merge(user=user, task_id=task_id, job_id=job_id)
def _test_merge_403(self, user: str, *, task_id: int | None = None, job_id: int | None = None):
response = self.merge(user=user, task_id=task_id, job_id=job_id, raise_on_error=False)
assert response.status == HTTPStatus.FORBIDDEN
return response
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_merge_in_sandbox_task(self, is_staff, allow, find_sandbox_task_with_consensus):
task, user = find_sandbox_task_with_consensus(is_staff)
if allow:
self._test_merge_200(user["username"], task_id=task["id"])
else:
self._test_merge_403(user["username"], task_id=task["id"])
@pytest.mark.parametrize(*_PermissionTestBase._default_org_cases)
def test_user_merge_in_org_task(
self,
find_org_task_with_consensus,
org_role,
is_staff,
allow,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
if allow:
self._test_merge_200(user["username"], task_id=task["id"])
else:
self._test_merge_403(user["username"], task_id=task["id"])
# users with task:view rights can check status of report creation
def _test_check_merge_status(
self,
rq_id: str,
*,
staff_user: str,
another_user: str,
another_user_status: int = HTTPStatus.FORBIDDEN,
):
with make_api_client(another_user) as api_client:
_, response = api_client.requests_api.retrieve(
rq_id, _parse_response=False, _check_status=False
)
assert response.status == another_user_status
with make_api_client(staff_user) as api_client:
wait_background_request(api_client, rq_id)
def test_user_without_rights_cannot_check_status_of_merge_in_sandbox(
self,
find_sandbox_task_with_consensus,
users,
):
task, task_staff = find_sandbox_task_with_consensus(is_staff=True)
another_user = next(
u
for u in users
if (
u["id"] != task_staff["id"]
and not u["is_superuser"]
and u["id"] != task["owner"]["id"]
and u["id"] != (task["assignee"] or {}).get("id")
)
)
rq_id = self.request_merge(task_id=task["id"], user=task_staff["username"])
self._test_check_merge_status(
rq_id, staff_user=task_staff["username"], another_user=another_user["username"]
)
@pytest.mark.parametrize(
"same_org, role",
[
pair
for pair in product([True, False], _PermissionTestBase._default_org_roles)
if not (pair[0] and pair[1] in ["owner", "maintainer"])
],
)
def test_user_without_rights_cannot_check_status_of_merge_in_org(
self,
find_org_task_with_consensus,
same_org: bool,
role: str,
organizations,
):
task, task_staff = find_org_task_with_consensus(is_staff=True, user_org_role="supervisor")
# create a new user that passes the requirements
another_user = register_new_user(f"{same_org}{role}")
org_id = (
task["organization"]
if same_org
else next(o for o in organizations if o["id"] != task["organization"])["id"]
)
invite_user_to_org(another_user["email"], org_id, role)
rq_id = self.request_merge(task_id=task["id"], user=task_staff["username"])
self._test_check_merge_status(
rq_id, staff_user=task_staff["username"], another_user=another_user["username"]
)
@pytest.mark.parametrize(
"role",
# owner and maintainer have rights even without being assigned to a task
("supervisor", "worker"),
)
def test_task_assignee_can_check_status_of_merge_in_org(
self,
find_org_task_with_consensus,
role: str,
):
task, another_user = find_org_task_with_consensus(is_staff=False, user_org_role=role)
task_owner = task["owner"]
rq_id = self.request_merge(task_id=task["id"], user=task_owner["username"])
self._test_check_merge_status(
rq_id,
staff_user=task_owner["username"],
another_user=another_user["username"],
)
with make_api_client(task_owner["username"]) as api_client:
api_client.tasks_api.partial_update(
task["id"],
patched_task_write_request=models.PatchedTaskWriteRequest(
assignee_id=another_user["id"]
),
)
self._test_check_merge_status(
rq_id,
staff_user=task_owner["username"],
another_user=another_user["username"],
another_user_status=HTTPStatus.OK,
)
@pytest.mark.parametrize("is_sandbox", (True, False))
def test_admin_can_check_status_of_merge(
self,
find_org_task_with_consensus,
find_sandbox_task_with_consensus,
users,
is_sandbox: bool,
):
if is_sandbox:
task, task_staff = find_sandbox_task_with_consensus(is_staff=True)
else:
task, task_staff = find_org_task_with_consensus(is_staff=True, user_org_role="owner")
admin = next(
u
for u in users
if (
u["is_superuser"]
and u["id"] != task_staff["id"]
and u["id"] != task["owner"]["id"]
and u["id"] != (task["assignee"] or {}).get("id")
)
)
rq_id = self.request_merge(task_id=task["id"], user=task_staff["username"])
with make_api_client(admin["username"]) as api_client:
wait_background_request(api_client, rq_id)
class TestSimpleConsensusSettingsFilters(CollectionSimpleFilterTestBase):
@pytest.fixture(autouse=True)
def setup(self, admin_user, consensus_settings):
self.user = admin_user
self.samples = consensus_settings
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.consensus_api.list_settings_endpoint
@pytest.mark.parametrize("field", ("task_id",))
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
class TestListSettings(_PermissionTestBase):
def _test_list_settings_200(
self, user: str, task_id: int, *, expected_data: dict[str, Any] | None = None, **kwargs
):
with make_api_client(user) as api_client:
actual = get_paginated_collection(
api_client.consensus_api.list_settings_endpoint,
task_id=task_id,
**kwargs,
return_json=True,
)
if expected_data is not None:
assert DeepDiff(expected_data, actual, ignore_order=True) == {}
def _test_list_settings_403(self, user: str, task_id: int, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.list_settings(
task_id=task_id, **kwargs, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
return response
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_list_settings_in_sandbox_task(
self, is_staff, allow, find_sandbox_task_with_consensus, consensus_settings
):
task, user = find_sandbox_task_with_consensus(is_staff)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
if allow:
self._test_list_settings_200(user["username"], task["id"], expected_data=[settings])
else:
self._test_list_settings_403(user["username"], task["id"])
@pytest.mark.parametrize(*_PermissionTestBase._default_org_cases)
def test_user_list_settings_in_org_task(
self,
consensus_settings,
find_org_task_with_consensus,
org_role: str,
is_staff,
allow: bool,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
org_id = task["organization"]
if allow:
self._test_list_settings_200(
user["username"], task["id"], expected_data=[settings], org_id=org_id
)
else:
self._test_list_settings_403(user["username"], task["id"], org_id=org_id)
class TestGetSettings(_PermissionTestBase):
def _test_get_settings_200(
self, user: str, obj_id: int, *, expected_data: dict[str, Any] | None = None, **kwargs
):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.retrieve_settings(obj_id, **kwargs)
assert response.status == HTTPStatus.OK
if expected_data is not None:
assert DeepDiff(expected_data, json.loads(response.data), ignore_order=True) == {}
return response
def _test_get_settings_403(self, user: str, obj_id: int, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.retrieve_settings(
obj_id, **kwargs, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
return response
def test_can_get_settings(self, admin_user, consensus_settings):
settings = next(iter(consensus_settings))
self._test_get_settings_200(admin_user, settings["id"], expected_data=settings)
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_get_settings_in_sandbox_task(
self, is_staff, allow, find_sandbox_task_with_consensus, consensus_settings
):
task, user = find_sandbox_task_with_consensus(is_staff)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
if allow:
self._test_get_settings_200(user["username"], settings["id"], expected_data=settings)
else:
self._test_get_settings_403(user["username"], settings["id"])
@pytest.mark.parametrize(*_PermissionTestBase._default_org_cases)
def test_user_get_settings_in_org_task(
self,
consensus_settings,
find_org_task_with_consensus,
org_role: str,
is_staff,
allow: bool,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
if allow:
self._test_get_settings_200(user["username"], settings["id"], expected_data=settings)
else:
self._test_get_settings_403(user["username"], settings["id"])
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchSettings(_PermissionTestBase):
def _test_patch_settings_200(
self,
user: str,
obj_id: int,
data: dict[str, Any],
*,
expected_data: dict[str, Any] | None = None,
**kwargs,
):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.partial_update_settings(
obj_id, patched_consensus_settings_request=data, **kwargs
)
assert response.status == HTTPStatus.OK
if expected_data is not None:
assert DeepDiff(expected_data, json.loads(response.data), ignore_order=True) == {}
return response
def _test_patch_settings_403(self, user: str, obj_id: int, data: dict[str, Any], **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.partial_update_settings(
obj_id,
patched_consensus_settings_request=data,
**kwargs,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
return response
def _get_request_data(self, data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
patched_data = deepcopy(data)
for field, value in data.items():
if isinstance(value, bool):
patched_data[field] = not value
elif isinstance(value, float):
patched_data[field] = 1 - value
expected_data = deepcopy(patched_data)
return patched_data, expected_data
def test_can_patch_settings(self, admin_user, consensus_settings):
settings = next(iter(consensus_settings))
data, expected_data = self._get_request_data(settings)
self._test_patch_settings_200(admin_user, settings["id"], data, expected_data=expected_data)
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_patch_settings_in_sandbox_task(
self, consensus_settings, find_sandbox_task_with_consensus, is_staff: bool, allow: bool
):
task, user = find_sandbox_task_with_consensus(is_staff)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
request_data, expected_data = self._get_request_data(settings)
if allow:
self._test_patch_settings_200(
user["username"], settings["id"], request_data, expected_data=expected_data
)
else:
self._test_patch_settings_403(user["username"], settings["id"], request_data)
@pytest.mark.parametrize(
"org_role, is_staff, allow",
[
("owner", True, True),
("owner", False, True),
("maintainer", True, True),
("maintainer", False, True),
("supervisor", True, True),
("supervisor", False, False),
("worker", True, True),
("worker", False, False),
],
)
def test_user_patch_settings_in_org_task(
self,
consensus_settings,
find_org_task_with_consensus,
org_role: str,
is_staff: bool,
allow: bool,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
request_data, expected_data = self._get_request_data(settings)
if allow:
self._test_patch_settings_200(
user["username"], settings["id"], request_data, expected_data=expected_data
)
else:
self._test_patch_settings_403(user["username"], settings["id"], request_data)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestMerging(_PermissionTestBase):
@pytest.mark.parametrize("task_id", [31])
def test_merged_annotations_have_scores(self, admin_user, jobs, labels, task_id: int):
"""Test that merged annotations have score attributes based on agreement."""
task_labels = [l for l in labels if l.get("task_id") == task_id]
task_jobs = [j for j in jobs if j["task_id"] == task_id]
parent_job = next(
j for j in task_jobs if j["type"] == "annotation" if j["consensus_replicas"] > 0
)
replicas = [
j
for j in task_jobs
if j["type"] == "consensus_replica"
if j["parent_job_id"] == parent_job["id"]
]
with make_api_client(admin_user) as api_client:
api_client.tasks_api.destroy_annotations(task_id)
for replica in replicas:
api_client.jobs_api.destroy_annotations(replica["id"])
bbox_full_agreement = models.LabeledShapeRequest(
type="rectangle",
frame=parent_job["start_frame"],
label_id=task_labels[0]["id"],
points=[0, 0, 2, 2],
attributes=[
{"spec_id": attr["id"], "value": attr["default_value"]}
for attr in task_labels[0]["attributes"]
],
rotation=0,
z_order=0,
occluded=False,
outside=False,
group=0,
)
bbox_partial_agreement = models.LabeledShapeRequest(
type="rectangle",
frame=parent_job["start_frame"],
label_id=task_labels[0]["id"],
points=[4, 0, 6, 2],
rotation=0,
z_order=0,
occluded=False,
outside=False,
group=0,
)
for replica in replicas:
api_client.jobs_api.update_annotations(
replica["id"],
labeled_data_request=models.LabeledDataRequest(shapes=[bbox_full_agreement]),
)
api_client.jobs_api.update_annotations(
replicas[0]["id"],
labeled_data_request=models.LabeledDataRequest(
shapes=[bbox_full_agreement, bbox_partial_agreement]
),
)
self.merge(job_id=parent_job["id"], user=admin_user)
merged_annotations = json.loads(
api_client.jobs_api.retrieve_annotations(parent_job["id"])[1].data
)
assert compare_annotations(
merged_annotations,
{
"shapes": [
{**bbox_full_agreement.to_dict(), "source": "consensus", "score": 1.0},
{
**bbox_partial_agreement.to_dict(),
"source": "consensus",
"score": 1 / len(replicas),
},
]
},
ignore_source=False,
ignore_score=False,
)
+164
View File
@@ -0,0 +1,164 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from http import HTTPStatus
import pytest
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from shared.utils.config import get_method, make_api_client, post_method
from .utils import CollectionSimpleFilterTestBase
class TestCreateInvitations:
ROLES = ["worker", "supervisor", "maintainer", "owner"]
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_function, organizations, memberships, admin_user):
self.org_id = 2
self.owner = self.get_member("owner", memberships, self.org_id)
def _test_post_invitation_201(self, user, data, invitee, **kwargs):
response = post_method(user, "invitations", data, **kwargs)
assert response.status_code == HTTPStatus.CREATED, response.content
assert data["role"] == response.json()["role"]
assert invitee["id"] == response.json()["user"]["id"]
assert kwargs["org_id"] == response.json()["organization"]
def _test_post_invitation_403(self, user, data, **kwargs):
response = post_method(user, "invitations", data, **kwargs)
assert response.status_code == HTTPStatus.FORBIDDEN, response.content
assert "You do not have permission" in str(response.content)
@staticmethod
def get_non_member_users(memberships, users):
organization_users = set(m["user"]["id"] for m in memberships if m["user"] is not None)
non_member_users = [u for u in users if u["id"] not in organization_users]
return non_member_users
@staticmethod
def get_member(role, memberships, org_id):
member = [
m["user"]
for m in memberships
if m["role"] == role and m["organization"] == org_id and m["user"] is not None
][0]
return member
@pytest.mark.parametrize("org_role", ROLES)
@pytest.mark.parametrize("invitee_role", ROLES)
def test_create_invitation(self, organizations, memberships, users, org_role, invitee_role):
org_id = self.org_id
inviter_user = self.get_member(org_role, memberships, org_id)
invitee_user = self.get_non_member_users(memberships, users)[0]
if org_role in ["worker", "supervisor"]:
self._test_post_invitation_403(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
org_id=org_id,
)
elif invitee_role in ["worker", "supervisor"]:
self._test_post_invitation_201(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
invitee_user,
org_id=org_id,
)
elif invitee_role == "maintainer":
if org_role == "owner":
# only the owner can invite a maintainer
self._test_post_invitation_201(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
invitee_user,
org_id=org_id,
)
else:
self._test_post_invitation_403(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
org_id=org_id,
)
elif invitee_role == "owner":
# nobody can invite an owner
self._test_post_invitation_403(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
org_id=org_id,
)
else:
assert False, "Unknown role"
class TestInvitationsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"user_id": ["user", "id"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, invitations):
self.user = admin_user
self.samples = invitations
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.invitations_api.list_endpoint
@pytest.mark.parametrize(
"field",
("owner", "user_id", "accepted"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_class")
class TestListInvitations:
def _test_can_see_invitations(self, user, data, **kwargs):
response = get_method(user, "invitations", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def test_admin_can_see_all_invitations(self, invitations):
self._test_can_see_invitations("admin2", invitations.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(1, 1), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, invitations):
invitations = filter(lambda i: i["organization"] == field_value, invitations)
self._test_can_see_invitations(
"admin2", list(invitations), page_size="all", org_id=query_value
)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetInvitations:
def test_can_remove_owner_and_fetch_with_sdk(self, admin_user, invitations):
# test for API schema regressions
source_inv = next(
invitations
for invitations in invitations
if invitations.get("owner") and invitations["owner"]["username"] != admin_user
).copy()
with make_api_client(admin_user) as api_client:
api_client.users_api.destroy(source_inv["owner"]["id"])
_, response = api_client.invitations_api.retrieve(source_inv["key"])
fetched_inv = json.loads(response.data)
source_inv["owner"] = None
assert DeepDiff(source_inv, fetched_inv, ignore_order=True) == {}
+407
View File
@@ -0,0 +1,407 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from copy import deepcopy
from http import HTTPStatus
from typing import Any
import pytest
from cvat_sdk import models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from shared.utils.config import get_method, make_api_client
from .utils import CollectionSimpleFilterTestBase
@pytest.mark.usefixtures("restore_db_per_function")
class TestPostIssues:
def _test_check_response(self, user, data, is_allow, **kwargs):
with make_api_client(user) as client:
_, response = client.issues_api.create(
models.IssueWriteRequest(**data),
**kwargs,
_parse_response=False,
_check_status=False,
)
if is_allow:
assert response.status == HTTPStatus.CREATED
response_json = json.loads(response.data)
assert user == response_json["owner"]["username"]
with make_api_client(user) as client:
comments, _ = client.comments_api.list(issue_id=response_json["id"])
assert data["message"] == comments.results[0].message
assert (
DeepDiff(
data,
response_json,
exclude_regex_paths=r"root\['created_date|updated_date|comments|id|owner|message'\]",
)
== {}
)
else:
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("org", [""])
@pytest.mark.parametrize(
"privilege, job_staff, is_allow",
[
("admin", True, True),
("admin", False, True),
("worker", True, True),
("worker", False, False),
("user", True, True),
("user", False, False),
],
)
def test_user_create_issue(
self, org, privilege, job_staff, is_allow, find_job_staff_user, find_users, jobs_by_org
):
users = find_users(privilege=privilege)
jobs = jobs_by_org[org]
username, jid = find_job_staff_user(jobs, users, job_staff)
(job,) = filter(lambda job: job["id"] == jid, jobs)
data = {
"assignee": None,
"comments": [],
"job": jid,
"frame": job["start_frame"],
"position": [
0.0,
0.0,
1.0,
1.0,
],
"resolved": False,
"message": "lorem ipsum",
}
self._test_check_response(username, data, is_allow)
@pytest.mark.parametrize("org", [2])
@pytest.mark.parametrize(
"role, job_staff, is_allow",
[
("maintainer", False, True),
("owner", False, True),
("supervisor", False, False),
("worker", False, False),
("maintainer", True, True),
("owner", True, True),
("supervisor", True, True),
("worker", True, True),
],
)
def test_member_create_issue(
self, org, role, job_staff, is_allow, find_job_staff_user, find_users, jobs_by_org, jobs
):
users = find_users(role=role, org=org)
username, jid = find_job_staff_user(jobs_by_org[org], users, job_staff)
job = jobs[jid]
data = {
"assignee": None,
"comments": [],
"job": jid,
"frame": job["start_frame"],
"position": [
0.0,
0.0,
1.0,
1.0,
],
"resolved": False,
"message": "lorem ipsum",
}
self._test_check_response(username, data, is_allow, org_id=org)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchIssues:
def _test_check_response(self, user, issue_id, data, is_allow, **kwargs):
request_data, expected_response_data = data
with make_api_client(user) as client:
_, response = client.issues_api.partial_update(
issue_id,
patched_issue_write_request=models.PatchedIssueWriteRequest(**request_data),
**kwargs,
_parse_response=False,
_check_status=False,
)
if is_allow:
assert response.status == HTTPStatus.OK
assert (
DeepDiff(
expected_response_data,
json.loads(response.data),
exclude_regex_paths=r"root\['created_date|updated_date|comments|id|owner'\]",
)
== {}
)
else:
assert response.status == HTTPStatus.FORBIDDEN
@pytest.fixture(scope="class")
def request_and_response_data(self, issues, users):
def get_data(issue_id, *, username: str = None):
request_data = deepcopy(issues[issue_id])
request_data["resolved"] = not request_data["resolved"]
response_data = deepcopy(request_data)
request_data.pop("comments")
request_data.pop("updated_date")
request_data.pop("id")
request_data.pop("owner")
if username:
assignee = next(u for u in users if u["username"] == username)
request_data["assignee"] = assignee["id"]
response_data["assignee"] = {
k: assignee[k] for k in ["id", "username", "url", "first_name", "last_name"]
}
else:
request_data["assignee"] = None
return request_data, response_data
return get_data
@pytest.mark.parametrize("org", [""])
@pytest.mark.parametrize(
"privilege, issue_staff, issue_admin, is_allow",
[
("admin", True, None, True),
("admin", False, None, True),
("user", True, None, True),
("user", False, None, False),
("worker", False, True, True),
("worker", True, False, True),
("worker", False, False, False),
],
)
def test_user_update_issue(
self,
org,
privilege,
issue_staff,
issue_admin,
is_allow,
find_issue_staff_user,
find_users,
issues_by_org,
request_and_response_data,
):
users = find_users(privilege=privilege)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
data = request_and_response_data(issue_id, username=username)
self._test_check_response(username, issue_id, data, is_allow)
@pytest.mark.parametrize("org", [2])
@pytest.mark.parametrize(
"role, issue_staff, issue_admin, is_allow",
[
("maintainer", True, None, True),
("maintainer", False, None, True),
("supervisor", True, None, True),
("supervisor", False, None, False),
("owner", True, None, True),
("owner", False, None, True),
("worker", False, True, True),
("worker", True, False, True),
("worker", False, False, False),
],
)
def test_member_update_issue(
self,
org,
role,
issue_staff,
issue_admin,
is_allow,
find_issue_staff_user,
find_users,
issues_by_org,
request_and_response_data,
):
users = find_users(role=role, org=org)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
data = request_and_response_data(issue_id, username=username)
self._test_check_response(username, issue_id, data, is_allow)
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteIssues:
def _test_check_response(self, user, issue_id, expect_success, **kwargs):
with make_api_client(user) as client:
_, response = client.issues_api.destroy(
issue_id,
**kwargs,
_parse_response=False,
_check_status=False,
)
if expect_success:
assert response.status == HTTPStatus.NO_CONTENT
_, response = client.issues_api.retrieve(
issue_id, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.NOT_FOUND
else:
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("org", [""])
@pytest.mark.parametrize(
"privilege, issue_staff, issue_admin, expect_success",
[
("admin", True, None, True),
("admin", False, None, True),
("user", True, None, True),
("user", False, None, False),
("worker", False, True, True),
("worker", True, False, False),
("worker", False, False, False),
],
)
def test_user_delete_issue(
self,
org,
privilege,
issue_staff,
issue_admin,
expect_success,
find_issue_staff_user,
find_users,
issues_by_org,
):
users = find_users(privilege=privilege)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
self._test_check_response(username, issue_id, expect_success)
@pytest.mark.parametrize("org", [2])
@pytest.mark.parametrize(
"role, issue_staff, issue_admin, expect_success",
[
("maintainer", True, None, True),
("maintainer", False, None, True),
("supervisor", True, None, True),
("supervisor", False, None, False),
("owner", True, None, True),
("owner", False, None, True),
("worker", False, True, True),
("worker", True, False, False),
("worker", False, False, False),
],
)
def test_org_member_delete_issue(
self,
org,
role,
issue_staff,
issue_admin,
expect_success,
find_issue_staff_user,
find_users,
issues_by_org,
):
users = find_users(role=role, org=org)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
self._test_check_response(username, issue_id, expect_success)
class TestIssuesListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"assignee": ["assignee", "username"],
"job_id": ["job"],
"frame_id": ["frame"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, issues):
self.user = admin_user
self.samples = issues
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.issues_api.list_endpoint
@pytest.mark.parametrize(
"field",
("owner", "assignee", "job_id", "resolved", "frame_id"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
class TestCommentsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"issue_id": ["issue"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, comments, issues):
self.user = admin_user
self.samples = comments
self.sample_issues = issues
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.comments_api.list_endpoint
def _get_field_samples(self, field: str) -> tuple[Any, list[dict[str, Any]]]:
if field == "job_id":
issue_id, issue_comments = super()._get_field_samples("issue_id")
issue = next(s for s in self.sample_issues if s["id"] == issue_id)
return issue["job"], issue_comments
elif field == "frame_id":
frame_id = self._find_valid_field_value(self.sample_issues, ["frame"])
issues = [s["id"] for s in self.sample_issues if s["frame"] == frame_id]
comments = [
s for s in self.samples if self._get_field(s, self._map_field("issue_id")) in issues
]
return frame_id, comments
else:
return super()._get_field_samples(field)
@pytest.mark.parametrize(
"field",
("owner", "issue_id", "job_id", "frame_id"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_class")
class TestListIssues:
def _test_can_see_issues(self, user, data, **kwargs):
response = get_method(user, "issues", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def test_admin_can_see_all_issues(self, issues):
self._test_can_see_issues("admin2", issues.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(1, 1), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, issues, jobs):
issues = filter(lambda i: jobs[i["job"]]["organization"] == field_value, issues)
self._test_can_see_issues("admin2", list(issues), page_size="all", org_id=query_value)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+221
View File
@@ -0,0 +1,221 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from http import HTTPStatus
from typing import ClassVar
import pytest
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.api_client.exceptions import ForbiddenException
from deepdiff import DeepDiff
from shared.utils.config import get_method, make_api_client, patch_method
from .utils import CollectionSimpleFilterTestBase
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetMemberships:
def _test_can_see_memberships(self, user, data, **kwargs):
response = get_method(user, "memberships", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def _test_cannot_see_memberships(self, user, **kwargs):
response = get_method(user, "memberships", **kwargs)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_admin_can_see_all_memberships(self, memberships):
self._test_can_see_memberships("admin2", memberships.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(1, 1), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, memberships):
memberships = filter(lambda m: m["organization"] == field_value, memberships)
self._test_can_see_memberships(
"admin2", list(memberships), page_size="all", org_id=query_value
)
def test_non_admin_can_see_only_self_memberships(self, memberships):
non_admins = ["user1", "dummy1", "worker2"]
for username in non_admins:
data = [obj for obj in memberships if obj["user"]["username"] == username]
self._test_can_see_memberships(username, data)
def test_all_members_can_see_other_members_membership(self, memberships):
data = [obj for obj in memberships if obj["organization"] == 1]
for membership in data:
self._test_can_see_memberships(membership["user"]["username"], data, org_id=1)
def test_non_members_cannot_see_members_membership(self):
non_org1_users = ["user2", "worker3"]
for user in non_org1_users:
self._test_cannot_see_memberships(user, org_id=1)
class TestMembershipsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"user": ["user", "username"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, memberships):
self.user = admin_user
self.samples = memberships
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.memberships_api.list_endpoint
@pytest.mark.parametrize(
"field",
("role", "user"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchMemberships:
_ORG: ClassVar[int] = 1
ROLES: ClassVar[list[str]] = ["worker", "supervisor", "maintainer", "owner"]
def _test_can_change_membership(self, user, membership_id, new_role):
response = patch_method(
user, f"memberships/{membership_id}", {"role": new_role}, org_id=self._ORG
)
assert response.status_code == HTTPStatus.OK
assert response.json()["role"] == new_role
def _test_cannot_change_membership(self, user, membership_id, new_role):
response = patch_method(
user, f"memberships/{membership_id}", {"role": new_role}, org_id=self._ORG
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize(
"who, whom, new_role, is_allow",
[
("worker", "worker", "supervisor", False),
("worker", "supervisor", "worker", False),
("worker", "maintainer", "worker", False),
("worker", "owner", "worker", False),
("supervisor", "worker", "supervisor", False),
("supervisor", "supervisor", "worker", False),
("supervisor", "maintainer", "supervisor", False),
("supervisor", "owner", "worker", False),
("maintainer", "maintainer", "worker", False),
("maintainer", "owner", "worker", False),
("maintainer", "supervisor", "worker", True),
("maintainer", "worker", "supervisor", True),
("owner", "maintainer", "worker", True),
("owner", "supervisor", "worker", True),
("owner", "worker", "supervisor", True),
],
)
def test_user_can_change_role_of_member(self, who, whom, new_role, is_allow, find_users):
user = find_users(org=self._ORG, role=who)[0]["username"]
membership_id = find_users(org=self._ORG, role=whom, exclude_username=user)[0][
"membership_id"
]
if is_allow:
self._test_can_change_membership(user, membership_id, new_role)
else:
self._test_cannot_change_membership(user, membership_id, new_role)
@pytest.mark.parametrize(
"who",
ROLES,
)
def test_user_cannot_change_self_role(self, who: str, find_users):
user = find_users(org=self._ORG, role=who)[0]
self._test_cannot_change_membership(
user["username"], user["membership_id"], self.ROLES[abs(self.ROLES.index(who) - 1)]
)
def test_malefactor_cannot_obtain_membership_details_via_empty_partial_update_request(
self, regular_lonely_user, memberships
):
membership = next(iter(memberships))
with make_api_client(regular_lonely_user) as api_client:
with pytest.raises(ForbiddenException):
api_client.memberships_api.partial_update(membership["id"])
def test_user_cannot_update_unknown_field(self, admin_user, memberships):
membership = next(iter(memberships))
response = patch_method(
admin_user, f"memberships/{membership['id']}", {"foo": "bar"}, org_id=self._ORG
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteMemberships:
_ORG: ClassVar[int] = 1
def _test_delete_membership(
self,
who: str,
membership_id: int,
is_allow: bool,
) -> None:
expected_status = HTTPStatus.NO_CONTENT if is_allow else HTTPStatus.FORBIDDEN
with make_api_client(who) as api_client:
_, response = api_client.memberships_api.destroy(membership_id, _check_status=False)
assert response.status == expected_status
@pytest.mark.parametrize(
"who, is_allow",
[
("worker", True),
("supervisor", True),
("maintainer", True),
("owner", False),
],
)
def test_member_can_leave_organization(self, who, is_allow, find_users):
user = find_users(role=who, org=self._ORG)[0]
self._test_delete_membership(user["username"], user["membership_id"], is_allow)
@pytest.mark.parametrize(
"who, whom, is_allow",
[
("worker", "worker", False),
("worker", "supervisor", False),
("worker", "maintainer", False),
("worker", "owner", False),
("supervisor", "worker", False),
("supervisor", "supervisor", False),
("supervisor", "maintainer", False),
("supervisor", "owner", False),
("maintainer", "worker", True),
("maintainer", "supervisor", True),
("maintainer", "maintainer", False),
("maintainer", "owner", False),
("owner", "worker", True),
("owner", "supervisor", True),
("owner", "maintainer", True),
],
)
def test_member_can_exclude_another_member(
self,
who: str,
whom: str,
is_allow: bool,
find_users,
):
user = find_users(role=who, org=self._ORG)[0]["username"]
membership_id = find_users(role=whom, org=self._ORG, exclude_username=user)[0][
"membership_id"
]
self._test_delete_membership(user, membership_id, is_allow)
+270
View File
@@ -0,0 +1,270 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from copy import deepcopy
from http import HTTPStatus
import pytest
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from shared.utils.config import (
delete_method,
get_method,
make_api_client,
options_method,
patch_method,
)
from .utils import CollectionSimpleFilterTestBase
class TestMetadataOrganizations:
_ORG = 2
@pytest.mark.parametrize(
"privilege, role, is_member",
[
("admin", None, None),
("user", None, False),
("worker", None, False),
(None, "owner", True),
(None, "maintainer", True),
(None, "worker", True),
(None, "supervisor", True),
],
)
def test_can_send_options_request(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
find_users,
organizations,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = options_method(user, f"organizations")
assert response.status_code == HTTPStatus.OK
response = options_method(user, f"organizations/{self._ORG}")
assert response.status_code == HTTPStatus.OK
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetOrganizations:
_ORG = 2
@pytest.mark.parametrize(
"privilege, role, is_member, is_allow",
[
("admin", None, None, True),
("user", None, False, False),
("worker", None, False, False),
(None, "owner", True, True),
(None, "maintainer", True, True),
(None, "worker", True, True),
(None, "supervisor", True, True),
],
)
def test_can_see_specific_organization(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
is_allow: bool,
find_users,
organizations,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = get_method(user, f"organizations/{self._ORG}")
if is_allow:
assert response.status_code == HTTPStatus.OK
assert DeepDiff(organizations[self._ORG], response.json()) == {}
else:
assert response.status_code == HTTPStatus.FORBIDDEN
def test_can_remove_owner_and_fetch_with_sdk(self, admin_user, organizations):
# test for API schema regressions
source_org = next(
org
for org in organizations
if org.get("owner") and org["owner"]["username"] != admin_user
).copy()
with make_api_client(admin_user) as api_client:
api_client.users_api.destroy(source_org["owner"]["id"])
_, response = api_client.organizations_api.retrieve(source_org["id"])
fetched_org = json.loads(response.data)
source_org["owner"] = None
assert DeepDiff(source_org, fetched_org, ignore_order=True) == {}
class TestOrganizationsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, organizations):
self.user = admin_user
self.samples = organizations
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.organizations_api.list_endpoint
@pytest.mark.parametrize(
"field",
("name", "owner", "slug"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchOrganizations:
_ORG = 2
@pytest.fixture(scope="class")
def request_data(self):
return {
"slug": "new",
"name": "new",
"description": "new",
"contact": {"email": "new@cvat.org"},
}
@pytest.fixture(scope="class")
def expected_data(self, organizations, request_data):
data = deepcopy(organizations[self._ORG])
data.update(request_data)
return data
@pytest.mark.parametrize(
"privilege, role, is_member, is_allow",
[
("admin", None, None, True),
("user", None, False, False),
("worker", None, False, False),
(None, "owner", True, True),
(None, "maintainer", True, True),
(None, "worker", True, False),
(None, "supervisor", True, False),
],
)
def test_can_update_specific_organization(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
is_allow: bool,
find_users,
request_data,
expected_data,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = patch_method(user, f"organizations/{self._ORG}", request_data)
if is_allow:
assert response.status_code == HTTPStatus.OK
assert (
DeepDiff(expected_data, response.json(), exclude_paths="root['updated_date']") == {}
)
else:
assert response.status_code != HTTPStatus.OK
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteOrganizations:
_ORG = 2
@pytest.mark.parametrize(
"privilege, role, is_member, is_allow",
[
("admin", None, None, True),
(None, "owner", True, True),
(None, "maintainer", True, False),
(None, "worker", True, False),
(None, "supervisor", True, False),
("user", None, False, False),
("worker", None, False, False),
],
)
def test_can_delete(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
is_allow: bool,
find_users,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = delete_method(user, f"organizations/{self._ORG}")
if is_allow:
assert response.status_code == HTTPStatus.NO_CONTENT
else:
assert response.status_code != HTTPStatus.OK
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from http import HTTPStatus
from time import sleep
from typing import Any
import pytest
from shared.utils.config import get_method, post_method
def _post_task_remote_data(username, task_id, resources):
data = {
"remote_files": resources,
"image_quality": 30,
}
return post_method(username, f"tasks/{task_id}/data", data)
def _wait_until_task_is_created(username: str, rq_id: str) -> dict[str, Any]:
url = f"requests/{rq_id}"
for _ in range(100):
response = get_method(username, url)
request_details = response.json()
if request_details["status"] in ("finished", "failed"):
return request_details
sleep(1)
raise Exception("Cannot create task")
@pytest.mark.usefixtures("restore_db_per_function")
class TestCreateFromRemote:
task_id = 12
def _test_can_create(self, user, task_id, resources):
response = _post_task_remote_data(user, task_id, resources)
assert response.status_code == HTTPStatus.ACCEPTED
response = response.json()
rq_id = response.get("rq_id")
assert rq_id, "The rq_id param was not found in the server response"
response_json = _wait_until_task_is_created(user, rq_id)
assert response_json["status"] == "finished"
def _test_cannot_create(self, user, task_id, resources):
response = _post_task_remote_data(user, task_id, resources)
assert response.status_code == HTTPStatus.ACCEPTED
response = response.json()
rq_id = response.get("rq_id")
assert rq_id, "The rq_id param was not found in the server response"
response_json = _wait_until_task_is_created(user, rq_id)
assert response_json["status"] == "failed"
return response_json
def test_can_report_failed_remote_file_download(self, find_users):
user = find_users(privilege="admin")[0]["username"]
remote_resources = ["http://localhost/favicon.ico"]
response_json = self._test_cannot_create(user, self.task_id, remote_resources)
message = response_json["message"]
assert "rest_framework.exceptions.ValidationError" in message, message
assert f"Failed to download {remote_resources[0]}" in message, message
def test_can_create(self, find_users):
user = find_users(privilege="admin")[0]["username"]
remote_resources = ["https://docs.cvat.ai/favicons/favicon-32x32.png"]
self._test_can_create(user, self.task_id, remote_resources)
+688
View File
@@ -0,0 +1,688 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import json
from http import HTTPStatus
from urllib.parse import parse_qsl, urlparse
import pytest
from cvat_sdk.api_client import ApiClient, models
from cvat_sdk.api_client.api_client import Endpoint
from cvat_sdk.core.helpers import get_paginated_collection
from shared.fixtures.data import Container
from shared.fixtures.init import docker_exec_redis_inmem, kube_exec_redis_inmem
from shared.utils.config import make_api_client
from shared.utils.helpers import generate_image_files
from .utils import (
CollectionSimpleFilterTestBase,
create_task,
export_dataset,
export_job_dataset,
export_project_backup,
export_project_dataset,
export_task_backup,
export_task_dataset,
import_job_annotations,
import_project_backup,
import_project_dataset,
import_task_annotations,
import_task_backup,
wait_background_request,
)
@pytest.mark.timeout(60)
@pytest.mark.usefixtures("restore_db_per_class")
@pytest.mark.usefixtures("restore_redis_inmem_per_class")
@pytest.mark.usefixtures("restore_redis_ondisk_per_class")
class TestRequestsListFilters(CollectionSimpleFilterTestBase):
# Org used for the org/org_id filter cases. Only the id is hardcoded;
# the slug is derived from the `organizations` fixture at setup time.
_ORG_ID = 2
field_lookups = {
"target": ["operation", "target"],
"subresource": ["operation", "type", lambda x: x.split(":")[1]],
"action": ["operation", "type", lambda x: x.split(":")[0]],
"project_id": ["operation", "project_id"],
"task_id": ["operation", "task_id"],
"job_id": ["operation", "job_id"],
"format": ["operation", "format"],
"org_id": ["operation", "org_id"],
}
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.requests_api.list_endpoint
@pytest.fixture(scope="class")
@classmethod
def setup_user(cls, restore_db_per_class, find_users):
cls.user = find_users(privilege="user")[0]["username"]
@pytest.fixture(scope="class")
@classmethod
def setup_org_users(cls, restore_db_per_class, find_users, organizations, memberships):
cls.org_slug = next(o["slug"] for o in organizations if o["id"] == cls._ORG_ID)
# Pick an owner/maintainer so they can create projects/tasks in the org.
cls.org_user = next(
find_users(id=m["user"]["id"])[0]["username"]
for m in memberships
if m["organization"] == cls._ORG_ID
and m["user"]
and m["role"] in ("owner", "maintainer")
)
@pytest.fixture(scope="class")
@classmethod
def fxt_resources_ids(
cls, setup_user, restore_redis_inmem_per_class, restore_redis_ondisk_per_class
):
with make_api_client(cls.user) as api_client:
project_ids = [
api_client.projects_api.create(
{"name": f"Test project {idx + 1}", "labels": [{"name": "car"}]}
)[0].id
for idx in range(3)
]
task_ids = [
create_task(
cls.user,
spec={"name": f"Test task {idx + 1}"},
data={
"image_quality": 75,
"client_files": generate_image_files(2),
"segment_size": 1,
},
)[0]
for idx in range(3)
]
job_ids = []
for task_id in task_ids:
jobs, _ = api_client.jobs_api.list(task_id=task_id)
job_ids.extend([j.id for j in jobs.results])
return project_ids, task_ids, job_ids
@pytest.fixture(scope="class")
@classmethod
def fxt_make_requests(
cls,
setup_user,
fxt_make_export_project_requests,
fxt_make_export_task_requests,
fxt_make_export_job_requests,
fxt_download_file,
):
def _make_requests(project_ids: list[int], task_ids: list[int], job_ids: list[int]):
# make requests to export projects|tasks|jobs annotations|datasets|backups
fxt_make_export_project_requests(project_ids[1:])
fxt_make_export_task_requests(task_ids[1:])
fxt_make_export_job_requests(job_ids[1:])
# make requests to download files and then import them
for resource_type, first_resource in zip(
("project", "task", "job"), (project_ids[0], task_ids[0], job_ids[0])
):
for subresource in ("dataset", "annotations", "backup"):
if resource_type == "job" and subresource == "backup":
continue
data = fxt_download_file(resource_type, first_resource, subresource)
tmp_file = io.BytesIO(data)
tmp_file.name = f"{resource_type}_{subresource}.zip"
if resource_type == "task" and subresource == "backup":
import_task_backup(
cls.user,
file_content=tmp_file,
)
empty_file = io.BytesIO(b"empty_file")
empty_file.name = "empty.zip"
# import corrupted backup
import_task_backup(
cls.user,
file_content=empty_file,
)
return _make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_download_file(cls, setup_user):
def download_file(resource: str, rid: int, subresource: str):
func = {
("project", "dataset"): lambda *args, **kwargs: export_project_dataset(
*args, **kwargs, save_images=True
),
("project", "annotations"): lambda *args, **kwargs: export_project_dataset(
*args, **kwargs, save_images=False
),
("project", "backup"): export_project_backup,
("task", "dataset"): lambda *args, **kwargs: export_task_dataset(
*args, **kwargs, save_images=True
),
("task", "annotations"): lambda *args, **kwargs: export_task_dataset(
*args, **kwargs, save_images=False
),
("task", "backup"): export_task_backup,
("job", "dataset"): lambda *args, **kwargs: export_job_dataset(
*args, **kwargs, save_images=True
),
("job", "annotations"): lambda *args, **kwargs: export_job_dataset(
*args, **kwargs, save_images=False
),
}[(resource, subresource)]
data = func(cls.user, id=rid, download_result=True)
assert data, f"Failed to download {resource} {subresource} locally"
return data
return download_file
@pytest.fixture(scope="class")
@classmethod
def fxt_make_export_project_requests(cls, setup_user):
def make_requests(project_ids: list[int]):
for project_id in project_ids:
export_project_backup(cls.user, id=project_id, download_result=False)
export_project_dataset(
cls.user, save_images=True, id=project_id, download_result=False
)
export_project_dataset(
cls.user,
save_images=False,
id=project_id,
download_result=False,
)
return make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_make_export_task_requests(cls, setup_user):
def make_requests(task_ids: list[int]):
for task_id in task_ids:
export_task_backup(cls.user, id=task_id, download_result=False)
export_task_dataset(cls.user, save_images=True, id=task_id, download_result=False)
export_task_dataset(cls.user, save_images=False, id=task_id, download_result=False)
return make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_make_export_job_requests(cls, setup_user):
def make_requests(job_ids: list[int]):
for job_id in job_ids:
export_job_dataset(
cls.user,
save_images=True,
id=job_id,
format="COCO 1.0",
download_result=False,
)
export_job_dataset(
cls.user,
save_images=False,
id=job_id,
format="YOLO 1.1",
download_result=False,
)
return make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_org_resources(cls, setup_org_users):
with make_api_client(cls.org_user) as api_client:
api_client.set_default_header("X-Organization", cls.org_slug)
cls.org_project_id = api_client.projects_api.create(
{"name": "Org test project", "labels": [{"name": "car"}]},
)[0].id
cls.org_task_id = create_task(
cls.org_user,
spec={"name": "Org test task"},
data={
"image_quality": 75,
"client_files": generate_image_files(2),
"segment_size": 1,
},
org_id=cls._ORG_ID,
)[0]
export_dataset(
api_client.projects_api,
save_images=True,
id=cls.org_project_id,
download_result=False,
)
export_dataset(
api_client.tasks_api,
save_images=True,
id=cls.org_task_id,
download_result=False,
)
@pytest.fixture(scope="class", autouse=True)
@classmethod
def setup(
cls,
setup_user,
setup_org_users,
fxt_resources_ids,
fxt_make_requests,
fxt_org_resources,
):
cls.project_ids, cls.task_ids, cls.job_ids = fxt_resources_ids
fxt_make_requests(cls.project_ids, cls.task_ids, cls.job_ids)
def _get_field_samples(self, field):
if field == "org":
gt_objects = [
s
for s in self.samples
if self._get_field(s, ["operation", "org_id"]) == self._ORG_ID
]
return self.org_slug, gt_objects
return super()._get_field_samples(field)
@pytest.mark.parametrize(
"simple_filter, values",
[
("subresource", ["annotations", "dataset", "backup"]),
("action", ["create", "export", "import"]),
("status", ["finished", "failed"]),
("project_id", []),
("task_id", []),
("job_id", []),
("format", ["CVAT for images 1.1", "COCO 1.0", "YOLO 1.1"]),
("target", ["project", "task", "job"]),
("org_id", []),
("org", []),
],
)
def test_can_use_simple_filter_for_object_list(self, simple_filter: str, values: list):
if simple_filter == "project_id":
values = self.project_ids[-1:]
elif simple_filter == "task_id":
values = self.task_ids[-1:]
elif simple_filter == "job_id":
values = self.job_ids[-1:]
elif simple_filter == "org_id":
values = [self._ORG_ID]
with make_api_client(self.user) as api_client:
self.samples = get_paginated_collection(
self._get_endpoint(api_client), return_json=True
)
return super()._test_can_use_simple_filter_for_object_list(simple_filter, values)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
def test_list_requests_when_there_is_job_with_non_regular_or_corrupted_meta(
jobs: Container, admin_user: str, request: pytest.FixtureRequest
):
job = next(j for j in jobs if j["media_type"] != "audio")
export_job_dataset(admin_user, save_images=True, id=job["id"], download_result=False)
export_job_dataset(admin_user, save_images=False, id=job["id"], download_result=False)
with make_api_client(admin_user) as api_client:
background_requests, response = api_client.requests_api.list(_check_status=False)
assert response.status == HTTPStatus.OK
assert 2 == background_requests.count
corrupted_job, normal_job = background_requests.results
corrupted_job_key = f"rq:job:{corrupted_job['id']}"
remove_meta_command = f'redis-cli -e HDEL "{corrupted_job_key}" meta'
if request.config.getoption("--platform") == "local":
stdout = docker_exec_redis_inmem(["sh", "-c", remove_meta_command])
else:
stdout = kube_exec_redis_inmem(
[
"sh",
"-c",
'export REDISCLI_AUTH="${REDIS_PASSWORD}" && ' + remove_meta_command,
]
)
assert bool(int(stdout.strip()))
_, response = api_client.requests_api.list(_check_status=False, _parse_response=False)
assert response.status == HTTPStatus.OK
background_requests = json.loads(response.data)
assert 1 == background_requests["count"]
assert normal_job.id == background_requests["results"][0]["id"]
@pytest.mark.usefixtures("restore_db_per_class")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestGetRequests:
def _test_get_request_200(
self, api_client: ApiClient, rq_id: str, validate_rq_id: bool = True, **kwargs
) -> models.Request:
background_request, response = api_client.requests_api.retrieve(rq_id, **kwargs)
assert response.status == HTTPStatus.OK
if validate_rq_id:
assert background_request.id == rq_id
return background_request
def _test_get_request_403(self, api_client: ApiClient, rq_id: str):
_, response = api_client.requests_api.retrieve(
rq_id, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("format_name", ("CVAT for images 1.1",))
@pytest.mark.parametrize("save_images", (True, False))
def test_owner_can_retrieve_request(self, format_name: str, save_images: bool, projects):
project = next(
p
for p in projects
if p["owner"] and (p["target_storage"] or {}).get("location") == "local"
)
owner = project["owner"]
subresource = "dataset" if save_images else "annotations"
request_id = export_project_dataset(
owner["username"],
save_images=save_images,
id=project["id"],
download_result=False,
format=format_name,
)
with make_api_client(owner["username"]) as owner_client:
bg_request = self._test_get_request_200(owner_client, request_id)
assert (
bg_request.created_date
< bg_request.started_date
< bg_request.finished_date
< bg_request.expiry_date
)
assert bg_request.operation.format == format_name
assert bg_request.operation.project_id == project["id"]
assert bg_request.operation.target == "project"
assert bg_request.operation.task_id is None
assert bg_request.operation.job_id is None
assert bg_request.operation.type == f"export:{subresource}"
assert bg_request.owner.id == owner["id"]
assert bg_request.owner.username == owner["username"]
parsed_url = urlparse(bg_request.result_url)
assert all([parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.query])
def test_non_owner_cannot_retrieve_request(self, find_users, projects):
project = next(
p
for p in projects
if p["owner"] and (p["target_storage"] or {}).get("location") == "local"
)
owner = project["owner"]
malefactor = find_users(exclude_username=owner["username"])[0]
request_id = export_project_dataset(
owner["username"],
save_images=True,
id=project["id"],
download_result=False,
)
with make_api_client(malefactor["username"]) as malefactor_client:
self._test_get_request_403(malefactor_client, request_id)
def _test_get_request_using_legacy_id(
self,
legacy_request_id: str,
username: str,
*,
action: str,
target_type: str,
subresource: str | None = None,
):
with make_api_client(username) as api_client:
bg_requests, _ = api_client.requests_api.list(
target=target_type,
action=action,
**({"subresource": subresource} if subresource else {}),
)
assert len(bg_requests.results) == 1
request_id = bg_requests.results[0].id
bg_request = self._test_get_request_200(
api_client, legacy_request_id, validate_rq_id=False
)
assert bg_request.id == request_id
@pytest.mark.parametrize("target_type", ("project", "task", "job"))
@pytest.mark.parametrize("save_images", (True, False))
@pytest.mark.parametrize("export_format", ("CVAT for images 1.1",))
@pytest.mark.parametrize("import_format", ("CVAT 1.1",))
def test_can_retrieve_dataset_import_export_requests_using_legacy_ids(
self,
target_type: str,
save_images: bool,
export_format: str,
import_format: str,
projects,
tasks,
jobs,
):
def build_legacy_id_for_export_request(
*,
target_type: str,
target_id: int,
subresource: str,
format_name: str,
user_id: int,
):
return f"export:{target_type}-{target_id}-{subresource}-in-{format_name.replace(' ', '_').replace('.', '@')}-format-by-{user_id}"
def build_legacy_id_for_import_request(
*,
target_type: str,
target_id: int,
subresource: str,
):
return f"import:{target_type}-{target_id}-{subresource}"
if target_type == "project":
export_func, import_func = export_project_dataset, import_project_dataset
target = next(p for p in projects if p["dimension"] != "1d")
owner = target["owner"]
elif target_type == "task":
export_func, import_func = export_task_dataset, import_task_annotations
target = next(t for t in tasks if t["media_type"] != "audio")
owner = target["owner"]
else:
assert target_type == "job"
export_func, import_func = export_job_dataset, import_job_annotations
target = next(j for j in jobs if j["media_type"] != "audio")
owner = tasks[target["task_id"]]["owner"]
target_id = target["id"]
subresource = "dataset" if save_images else "annotations"
file_content = io.BytesIO(
export_func(
owner["username"],
save_images=save_images,
format=export_format,
id=target_id,
)
)
file_content.name = "file.zip"
legacy_request_id = build_legacy_id_for_export_request(
target_type=target_type,
target_id=target["id"],
subresource=subresource,
format_name=export_format,
user_id=owner["id"],
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="export",
target_type=target_type,
subresource=subresource,
)
# check import requests
if not save_images and target_type == "project" or save_images and target_type != "project":
# skip:
# importing annotations into a project
# importing datasets into a task or job
return
import_func(
owner["username"],
file_content=file_content,
id=target_id,
format=import_format,
)
legacy_request_id = build_legacy_id_for_import_request(
target_type=target_type, target_id=target_id, subresource=subresource
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="import",
target_type=target_type,
subresource=subresource,
)
@pytest.mark.parametrize("target_type", ("project", "task"))
def test_can_retrieve_backup_import_export_requests_using_legacy_ids(
self,
target_type: str,
projects,
tasks,
):
def build_legacy_id_for_export_request(
*,
target_type: str,
target_id: int,
user_id: int,
):
return f"export:{target_type}-{target_id}-backup-by-{user_id}"
def build_legacy_id_for_import_request(
*,
target_type: str,
uuid_: str,
):
return f"import:{target_type}-{uuid_}-backup"
if target_type == "project":
export_func, import_func = export_project_backup, import_project_backup
target = next(iter(projects))
else:
assert target_type == "task"
export_func, import_func = export_task_backup, import_task_backup
target = next(iter(tasks))
owner = target["owner"]
# check export requests
backup_file = io.BytesIO(
export_func(
owner["username"],
id=target["id"],
)
)
backup_file.name = "file.zip"
legacy_request_id = build_legacy_id_for_export_request(
target_type=target_type, target_id=target["id"], user_id=owner["id"]
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="export",
target_type=target_type,
subresource="backup",
)
# check import requests
result_id = import_func(
owner["username"],
file_content=backup_file,
).id
legacy_request_id = build_legacy_id_for_import_request(
target_type=target_type, uuid_=dict(parse_qsl(result_id))["id"]
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="import",
target_type=target_type,
subresource="backup",
)
def test_can_retrieve_task_creation_requests_using_legacy_ids(self, admin_user: str):
task_id = create_task(
admin_user,
spec={"name": "Test task"},
data={
"image_quality": 75,
"client_files": generate_image_files(2),
"segment_size": 1,
},
)[0]
legacy_request_id = f"create:task-{task_id}"
self._test_get_request_using_legacy_id(
legacy_request_id, admin_user, action="create", target_type="task"
)
def test_can_retrieve_quality_calculation_requests_using_legacy_ids(self, jobs, tasks):
gt_job = next(
j
for j in jobs
if (
j["type"] == "ground_truth"
and j["stage"] == "acceptance"
and j["state"] == "completed"
)
)
task_id = gt_job["task_id"]
owner = tasks[task_id]["owner"]
legacy_request_id = f"quality-check-task-{task_id}-user-{owner['id']}"
with make_api_client(owner["username"]) as api_client:
# initiate quality report calculation
_, response = api_client.quality_api.create_report(
quality_report_create_request=models.QualityReportCreateRequest(task_id=task_id),
_parse_response=False,
)
assert response.status == HTTPStatus.ACCEPTED
request_id = json.loads(response.data)["rq_id"]
# get background request details using common request API
bg_request = self._test_get_request_200(
api_client, legacy_request_id, validate_rq_id=False
)
assert bg_request.id == request_id
# get quality report by legacy request ID using the deprecated API endpoint
wait_background_request(api_client, request_id)
api_client.quality_api.create_report(
quality_report_create_request=models.QualityReportCreateRequest(task_id=task_id),
rq_id=request_id,
)
@@ -0,0 +1,662 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from collections.abc import Callable
from http import HTTPStatus
from pathlib import Path
from typing import Any
from uuid import uuid4
import pytest
from cvat_sdk import exceptions
from cvat_sdk.core import Client as SdkClient
from cvat_sdk.core.progress import NullProgressReporter
from cvat_sdk.core.uploading import Uploader
from pytest_cases import fixture, fixture_ref, parametrize
from shared.fixtures.data import Container
from shared.utils.config import get_method, make_sdk_client, post_method
from shared.utils.resource_import_export import (
_CloudStorageResourceTest,
_make_export_resource_params,
_make_import_resource_params,
)
from shared.utils.s3 import make_client as make_s3_client
from .utils import create_task
class _S3ResourceTest(_CloudStorageResourceTest):
@staticmethod
def _make_client():
return make_s3_client()
@pytest.mark.with_external_services
@pytest.mark.usefixtures("restore_db_per_class")
class TestExportResourceToS3(_S3ResourceTest):
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize("cloud_storage_id", [3])
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "annotations"),
(2, "projects", "dataset"),
(2, "projects", "backup"),
(11, "tasks", "annotations"),
(11, "tasks", "dataset"),
(11, "tasks", "backup"),
(16, "jobs", "annotations"),
(16, "jobs", "dataset"),
],
)
def test_save_resource_to_cloud_storage_with_specific_location(
self, cloud_storage_id, obj_id, obj, resource, cloud_storages
):
cloud_storage = cloud_storages[cloud_storage_id]
kwargs = _make_export_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=cloud_storage_id
)
self._export_resource(cloud_storage, obj_id, obj, resource, **kwargs)
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize("user_type", ["admin", "assigned_supervisor_org_member"])
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "annotations"),
(2, "projects", "dataset"),
(2, "projects", "backup"),
(11, "tasks", "annotations"),
(11, "tasks", "dataset"),
(11, "tasks", "backup"),
(16, "jobs", "annotations"),
(16, "jobs", "dataset"),
],
)
def test_save_resource_to_cloud_storage_with_default_location(
self,
obj_id,
obj,
resource,
user_type,
projects,
tasks,
jobs,
cloud_storages,
users,
is_project_staff,
is_task_staff,
is_job_staff,
is_org_member,
):
objects = {
"projects": projects,
"tasks": tasks,
"jobs": jobs,
}
if obj in ("projects", "tasks"):
cloud_storage_id = objects[obj][obj_id]["target_storage"]["cloud_storage_id"]
else:
task_id = jobs[obj_id]["task_id"]
cloud_storage_id = tasks[task_id]["target_storage"]["cloud_storage_id"]
cloud_storage = cloud_storages[cloud_storage_id]
if user_type == "admin":
user = self.user
elif user_type == "assigned_supervisor_org_member":
is_staff = {"projects": is_project_staff, "tasks": is_task_staff, "jobs": is_job_staff}[
obj
]
user = next(
u
for u in users
if is_staff(u["id"], obj_id)
if is_org_member(u["id"], cloud_storage["organization"], role="supervisor")
)["username"]
else:
assert False
kwargs = _make_export_resource_params(resource, obj=obj)
kwargs["user"] = user
self._export_resource(cloud_storage, obj_id, obj, resource, **kwargs)
@pytest.mark.parametrize("storage_id", [3])
@pytest.mark.parametrize(
"obj, resource",
[
("projects", "annotations"),
("projects", "dataset"),
("projects", "backup"),
("tasks", "annotations"),
("tasks", "dataset"),
("tasks", "backup"),
("jobs", "annotations"),
("jobs", "dataset"),
],
)
def test_user_cannot_export_to_cloud_storage_with_specific_location_without_access(
self, storage_id, regular_lonely_user, obj, resource
):
user = regular_lonely_user
project_spec = {"name": "Test project"}
project = post_method(user, "projects", project_spec).json()
project_id = project["id"]
task_spec = {
"name": f"Task with files from foreign cloud storage {storage_id}",
}
data_spec = {
"image_quality": 75,
"use_cache": True,
"server_files": ["images/image_1.jpg"],
"project_id": project_id,
}
task_id, _ = create_task(user, task_spec, data_spec)
jobs = get_method(user, "jobs", task_id=task_id).json()["results"]
job_id = jobs[0]["id"]
if obj == "projects":
obj_id = project_id
elif obj == "tasks":
obj_id = task_id
elif obj == "jobs":
obj_id = job_id
else:
assert False
kwargs = _make_export_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=storage_id
)
self._export_resource_to_cloud_storage(
obj_id, obj, resource, user=user, _expect_status=HTTPStatus.FORBIDDEN, **kwargs
)
@pytest.mark.with_external_services
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_cvat_data_per_function")
class TestImportResourceFromS3(_S3ResourceTest):
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize("cloud_storage_id", [3])
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "dataset"),
(2, "projects", "backup"),
(11, "tasks", "annotations"),
(11, "tasks", "backup"),
(16, "jobs", "annotations"),
],
)
def test_import_resource_from_cloud_storage_with_specific_location(
self, cloud_storage_id, obj_id, obj, resource, cloud_storages
):
cloud_storage = cloud_storages[cloud_storage_id]
kwargs = _make_import_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=cloud_storage_id
)
export_kwargs = _make_export_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=cloud_storage_id
)
self._export_resource(cloud_storage, obj_id, obj, resource, **export_kwargs)
self._import_resource(
cloud_storage,
resource,
*(
[
obj_id,
]
if resource != "backup"
else []
),
obj,
**kwargs,
)
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize(
"user_type",
["admin", "assigned_supervisor_org_member"],
)
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "dataset"),
(11, "tasks", "annotations"),
(16, "jobs", "annotations"),
],
)
def test_import_resource_from_cloud_storage_with_default_location(
self,
obj_id,
obj,
resource,
user_type,
projects,
tasks,
jobs,
cloud_storages,
users,
is_project_staff,
is_task_staff,
is_job_staff,
is_org_member,
):
objects = {
"projects": projects,
"tasks": tasks,
"jobs": jobs,
}
if obj in ("projects", "tasks"):
cloud_storage_id = objects[obj][obj_id]["source_storage"]["cloud_storage_id"]
else:
task_id = jobs[obj_id]["task_id"]
cloud_storage_id = tasks[task_id]["source_storage"]["cloud_storage_id"]
cloud_storage = cloud_storages[cloud_storage_id]
if user_type == "admin":
user = self.user
elif user_type == "assigned_supervisor_org_member":
is_staff = {"projects": is_project_staff, "tasks": is_task_staff, "jobs": is_job_staff}[
obj
]
user = next(
u
for u in users
if is_staff(u["id"], obj_id)
if is_org_member(u["id"], cloud_storage["organization"], role="supervisor")
)["username"]
else:
assert False
export_kwargs = _make_export_resource_params(resource, obj=obj)
import_kwargs = _make_import_resource_params(resource, obj=obj)
export_kwargs["user"] = user
import_kwargs["user"] = user
self._export_resource(cloud_storage, obj_id, obj, resource, **export_kwargs)
self._import_resource(cloud_storage, resource, obj_id, obj, **import_kwargs)
@pytest.mark.parametrize("storage_id", [3])
@pytest.mark.parametrize(
"obj, resource",
[
("projects", "dataset"),
("tasks", "annotations"),
("jobs", "annotations"),
("tasks", "backup"),
("projects", "backup"),
],
)
def test_user_cannot_import_from_cloud_storage_with_specific_location_without_access(
self, storage_id, regular_lonely_user, obj, resource, cloud_storages
):
user = regular_lonely_user
project_spec = {"name": "Test project"}
project = post_method(user, "projects", project_spec).json()
project_id = project["id"]
task_spec = {
"name": f"Task with files from foreign cloud storage {storage_id}",
}
data_spec = {
"image_quality": 75,
"use_cache": True,
"server_files": ["images/image_1.jpg"],
"project_id": project_id,
}
task_id, _ = create_task(user, task_spec, data_spec)
jobs = get_method(user, "jobs", task_id=task_id).json()["results"]
job_id = jobs[0]["id"]
if obj == "projects":
obj_id = project_id
elif obj == "tasks":
obj_id = task_id
elif obj == "jobs":
obj_id = job_id
else:
assert False
cloud_storage = cloud_storages[storage_id]
kwargs = _make_import_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=storage_id
)
if resource == "annotations":
kwargs["_check_uploaded"] = False
self._import_resource(
cloud_storage,
resource,
*(
[
obj_id,
]
if resource != "backup"
else []
),
obj,
user=user,
_expect_status=HTTPStatus.FORBIDDEN,
**kwargs,
)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
class TestUploads:
@fixture()
def project(self, projects: Container):
return next(p for p in projects)
@fixture()
def task(self, tasks: Container):
return next(t for t in tasks)
@fixture()
def job(self, jobs: Container):
return next(j for j in jobs if j["type"] == "annotation")
@fixture(scope="class")
def restore_task_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.tasks_api.create_backup_endpoint.path
@fixture(scope="class")
def restore_project_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.projects_api.create_backup_endpoint.path
@fixture(scope="class")
def upload_task_annotations_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.tasks_api.create_annotations_endpoint.path
@fixture(scope="class")
def upload_project_dataset_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.projects_api.create_dataset_endpoint.path
@fixture(scope="class")
def upload_job_annotations_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.jobs_api.create_annotations_endpoint.path
def _find_malefactor_with_its_resource(
self,
resources: Container,
users: Container,
*,
get_owner_func: Callable = lambda r: r["owner"],
user_to_skip: int | None = None,
) -> tuple[dict, int]:
for resource in resources:
malefactor = get_owner_func(resource)
if not users[malefactor["id"]]["is_superuser"] and malefactor["id"] != user_to_skip:
return malefactor, resource["id"]
assert False
@fixture()
def downloaded_file_path(self, tmp_path: Path):
return tmp_path / f"{uuid4()}.zip"
@fixture()
def project_backup_with_owner_and_malefactor(
self,
project: dict,
projects: Container,
downloaded_file_path: Path,
restore_project_api_path: str,
users: Container,
):
project_id, project_owner = project["id"], project["owner"]
malefactor, _ = self._find_malefactor_with_its_resource(
projects, users, user_to_skip=project_owner["id"]
)
with make_sdk_client(project_owner["username"]) as client:
client.projects.retrieve(project_id).download_backup(downloaded_file_path)
return (
None,
downloaded_file_path,
project_owner,
malefactor,
None,
restore_project_api_path,
None,
)
@fixture()
def project_dataset_with_owner_and_malefactor(
self,
project: dict,
projects: Container,
downloaded_file_path: Path,
upload_project_dataset_api_path: str,
users: Container,
):
project_id, project_owner = project["id"], project["owner"]
malefactor, malefactor_project_id = self._find_malefactor_with_its_resource(
projects, users, user_to_skip=project_owner["id"]
)
with make_sdk_client(project_owner["username"]) as client:
client.projects.retrieve(project_id).export_dataset(
"COCO 1.0", downloaded_file_path, include_images=True
)
return (
project_id,
downloaded_file_path,
project_owner,
malefactor,
malefactor_project_id,
upload_project_dataset_api_path,
{"format": "COCO 1.0"},
)
@fixture()
def task_backup_with_owner_and_malefactor(
self,
task: dict,
tasks: Container,
downloaded_file_path: Path,
restore_task_api_path: str,
users: Container,
):
task_id = task["id"]
task_owner = task["owner"]
malefactor, _ = self._find_malefactor_with_its_resource(
tasks, users, user_to_skip=task_owner["id"]
)
with make_sdk_client(task_owner["username"]) as client:
client.tasks.retrieve(task_id).download_backup(downloaded_file_path)
return (
None,
downloaded_file_path,
task_owner,
malefactor,
None,
restore_task_api_path,
None,
)
@fixture()
def task_annotations_with_owner_and_malefactor(
self,
task: dict,
tasks: Container,
downloaded_file_path: Path,
upload_task_annotations_api_path: str,
users: Container,
):
task_id = task["id"]
task_owner = task["owner"]
malefactor, malefactor_task_id = self._find_malefactor_with_its_resource(
tasks, users, user_to_skip=task_owner["id"]
)
with make_sdk_client(task_owner["username"]) as client:
client.tasks.retrieve(task_id).export_dataset(
"COCO 1.0", downloaded_file_path, include_images=False
)
return (
task_id,
downloaded_file_path,
task_owner,
malefactor,
malefactor_task_id,
upload_task_annotations_api_path,
{"format": "COCO 1.0"},
)
@fixture()
def job_annotations_with_owner_and_malefactor(
self,
job: dict,
jobs: Container,
tasks: Container,
downloaded_file_path: Path,
upload_job_annotations_api_path: str,
users: Container,
):
job_id = job["id"]
job_owner = tasks[job["task_id"]]["owner"]
malefactor, malefactor_job_id = self._find_malefactor_with_its_resource(
jobs,
users,
user_to_skip=job_owner["id"],
get_owner_func=lambda x: tasks[x["task_id"]]["owner"],
)
with make_sdk_client(job_owner["username"]) as client:
client.jobs.retrieve(job_id).export_dataset(
"COCO 1.0", downloaded_file_path, include_images=False
)
return (
job_id,
downloaded_file_path,
job_owner,
malefactor,
malefactor_job_id,
upload_job_annotations_api_path,
{"format": "COCO 1.0"},
)
def _test_can_finish_upload(
self,
client: SdkClient,
url: str,
*,
query_params: dict[str, Any],
):
Uploader(client)._tus_finish_upload(url, query_params=query_params)
def _test_cannot_finish_upload(
self,
client: SdkClient,
url: str,
*,
query_params: dict[str, Any],
):
uploader = Uploader(client)
with pytest.raises(exceptions.ApiException) as capture:
uploader._tus_finish_upload(url, query_params=query_params)
assert capture.value.status == HTTPStatus.BAD_REQUEST
assert b"No such file were uploaded" in capture.value.body
@parametrize(
"resource, endpoint_path",
[
(None, fixture_ref(restore_task_api_path)),
(None, fixture_ref(restore_project_api_path)),
(fixture_ref(task), fixture_ref(upload_task_annotations_api_path)),
(fixture_ref(project), fixture_ref(upload_project_dataset_api_path)),
(fixture_ref(job), fixture_ref(upload_job_annotations_api_path)),
],
)
def test_user_cannot_restore_resource_from_non_existent_uploads(
self, resource: dict, endpoint_path: str, admin_user: str
):
with make_sdk_client(admin_user) as client:
url = client.api_map.make_endpoint_url(
endpoint_path, kwsub=({"id": resource["id"]} if resource else None)
)
self._test_cannot_finish_upload(
client, url, query_params={"filename": "non-existent-file.zip"}
)
@parametrize(
"src_resource_id, archive_path, owner, malefactor, dst_resource_id, endpoint_path, query_params",
[
(fixture_ref(task_backup_with_owner_and_malefactor)),
(fixture_ref(project_backup_with_owner_and_malefactor)),
(fixture_ref(job_annotations_with_owner_and_malefactor)),
(fixture_ref(task_annotations_with_owner_and_malefactor)),
(fixture_ref(project_dataset_with_owner_and_malefactor)),
],
)
def test_user_can_use_only_own_uploads(
self,
src_resource_id: int | None,
archive_path: Path,
owner: dict,
malefactor: dict,
dst_resource_id: str | None,
endpoint_path: str,
query_params: dict | None,
):
with (
make_sdk_client(owner["username"]) as owner_client,
make_sdk_client(malefactor["username"]) as malefactor_client,
):
params = {"filename": archive_path.name}
pbar = NullProgressReporter()
url = owner_client.api_map.make_endpoint_url(
endpoint_path, kwsub=({"id": src_resource_id} if src_resource_id else None)
)
uploader = Uploader(owner_client)
uploader._tus_start_upload(url, query_params=params)
with uploader._uploading_task(pbar, archive_path.stat().st_size):
upload_name = uploader._upload_file_data_with_tus(
url,
archive_path,
meta=params,
pbar=pbar,
)
query_params = {"filename": upload_name, **(query_params or {})}
# check that malefactor cannot use someone else's uploaded file
url_to_steal = malefactor_client.api_map.make_endpoint_url(
endpoint_path, kwsub=({"id": dst_resource_id} if dst_resource_id else None)
)
self._test_cannot_finish_upload(
malefactor_client, url=url_to_steal, query_params=query_params
)
# check that uploaded file still exists and owner can finish started process
self._test_can_finish_upload(owner_client, url=url, query_params=query_params)
+42
View File
@@ -0,0 +1,42 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from http import HTTPStatus
import pytest
from shared.utils.config import make_api_client, put_method
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetServer:
def test_can_retrieve_about_unauthenticated(self):
with make_api_client(user=None, password=None) as api_client:
data, response = api_client.server_api.retrieve_about()
assert response.status == HTTPStatus.OK
assert data.version
def test_can_retrieve_formats(self, admin_user: str):
with make_api_client(admin_user) as api_client:
data, response = api_client.server_api.retrieve_annotation_formats()
assert response.status == HTTPStatus.OK
assert len(data.importers) != 0
assert len(data.exporters) != 0
def test_method_not_allowed_for_existing_route(self, admin_user: str):
response = put_method(admin_user, "server/annotation/formats", data=None)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetSchema:
def test_can_get_schema_unauthenticated(self):
with make_api_client(user=None, password=None) as api_client:
data, response = api_client.schema_api.retrieve()
assert response.status == HTTPStatus.OK
assert data
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import base64
from http import HTTPStatus
import pytest
from shared.utils.config import BASE_URL, make_api_client
from shared.utils.helpers import generate_image_file
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_cvat_data_per_function")
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestTUSUpload:
"""Integration tests for TUS resumable upload protocol"""
_USERNAME = "admin1"
@pytest.fixture
def fxt_task(self):
"""Fixture to create a task for TUS upload tests"""
with make_api_client(self._USERNAME) as api_client:
task, response = api_client.tasks_api.create({"name": "test TUS upload"})
assert response.status == HTTPStatus.CREATED
return task
def _call_tus_endpoint(self, method, path, headers=None, body=None, check_status=True):
"""Call a low-level TUS endpoint via ApiClient"""
headers = headers or {}
with make_api_client(self._USERNAME) as api_client:
api_client.update_params_for_auth(headers=headers, queries=[], method=method)
response = api_client.request(
method,
path,
headers=headers,
body=body,
_parse_response=False,
_check_status=check_status,
)
return response
def _start_upload(self, task_id, file_size, filename):
"""Start TUS upload and return location URL"""
metadata = f"filename {base64.b64encode(filename.encode()).decode()}"
response = self._call_tus_endpoint(
"POST",
BASE_URL + f"/api/tasks/{task_id}/data/",
headers={
"Upload-Length": str(file_size),
"Upload-Metadata": metadata,
"Tus-Resumable": "1.0.0",
},
)
assert response.status == HTTPStatus.CREATED
location = response.headers.get("Location")
assert location is not None
return location
def _upload_chunk(self, location, offset, data, check_status=True):
"""Upload a chunk via TUS PATCH request"""
response = self._call_tus_endpoint(
"PATCH",
location,
headers={
"Upload-Offset": str(offset),
"Content-Type": "application/offset+octet-stream",
"Tus-Resumable": "1.0.0",
},
body=data,
check_status=check_status,
)
return response
def _get_upload_offset(self, location):
"""Get current upload offset via TUS HEAD request"""
response = self._call_tus_endpoint(
"HEAD",
location,
headers={"Tus-Resumable": "1.0.0"},
)
assert response.status == HTTPStatus.OK
offset = response.headers.get("Upload-Offset")
assert offset is not None
return int(offset)
def test_can_upload_file_via_tus_in_single_chunk(self, fxt_task):
"""Test uploading a complete file in one chunk"""
task = fxt_task
image_file = generate_image_file("test_image.jpg", size=(100, 100))
image_data = image_file.getvalue()
file_size = len(image_data)
location = self._start_upload(task.id, file_size, "test_image.jpg")
response = self._upload_chunk(location, 0, image_data)
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == file_size
def test_upload_offset_is_updated_incrementally(self, fxt_task):
"""Test offset updates correctly after each small chunk"""
task = fxt_task
image_file = generate_image_file("test_image.jpg", size=(150, 150))
image_data = image_file.getvalue()
file_size = len(image_data)
chunk_size = 1024
assert file_size > chunk_size, (
f"Image size {file_size} must be larger than chunk size {chunk_size} "
"to properly test incremental uploads"
)
location = self._start_upload(task.id, file_size, "test_image.jpg")
offset = 0
while offset < file_size:
chunk_end = min(offset + chunk_size, file_size)
chunk = image_data[offset:chunk_end]
response = self._upload_chunk(location, offset, chunk)
assert response.status == HTTPStatus.NO_CONTENT
new_offset = int(response.headers.get("Upload-Offset"))
assert new_offset == chunk_end
# Verify via HEAD only if not complete (file gets cleaned after completion)
if new_offset < file_size:
head_offset = self._get_upload_offset(location)
assert head_offset == new_offset
offset = new_offset
def test_cannot_upload_with_wrong_offset(self, fxt_task):
"""Test that upload fails if offset doesn't match"""
task = fxt_task
# Generate test data (no need for valid image since upload won't complete)
file_size = 10000
image_data = b"\x00" * file_size
location = self._start_upload(task.id, file_size, "test_image.jpg")
# Upload first chunk
chunk1 = image_data[:1000]
response = self._upload_chunk(location, 0, chunk1)
assert response.status == HTTPStatus.NO_CONTENT
# Get offset from response (file is not complete yet)
current_offset = int(response.headers.get("Upload-Offset"))
assert current_offset == 1000
# Try wrong offset below expected (should be 1000, we send 500)
chunk2 = image_data[1000:2000]
response = self._upload_chunk(location, 500, chunk2, check_status=False)
assert response.status == HTTPStatus.CONFLICT
# Try wrong offset above expected (should be 1000, we send 1500)
response = self._upload_chunk(location, 1500, chunk2, check_status=False)
assert response.status == HTTPStatus.CONFLICT
def test_cannot_upload_chunk_exceeding_file_size(self, fxt_task):
"""Test that server rejects chunks whose end exceeds file size"""
task = fxt_task
# Generate test data (no need for valid image since upload won't complete)
file_size = 10000
image_data = b"\x00" * file_size
location = self._start_upload(task.id, file_size, "test_image.jpg")
chunk1 = image_data[:1000]
response = self._upload_chunk(location, 0, chunk1)
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == 1000
# Try to upload chunk that would exceed file size
# offset=1000, chunk size would make end_offset > file_size
oversized_chunk = b"x" * (file_size - 500) # This will go beyond file_size
response = self._upload_chunk(location, 1000, oversized_chunk, check_status=False)
assert response.status == HTTPStatus.REQUEST_ENTITY_TOO_LARGE
def test_get_upload_offset_after_complete_upload(self, fxt_task):
"""Test uploading a complete file in one chunk"""
task = fxt_task
image_file = generate_image_file("test_image.jpg", size=(100, 100))
image_data = image_file.getvalue()
file_size = len(image_data)
location = self._start_upload(task.id, file_size, "test_image.jpg")
current_offset = self._get_upload_offset(location)
assert current_offset == 0
response = self._upload_chunk(location, 0, image_data)
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == file_size
# After completing the download, the HEAD request should return 404.
head_response = self._call_tus_endpoint(
"HEAD",
location,
headers={"Tus-Resumable": "1.0.0"},
check_status=False,
)
assert head_response.status == HTTPStatus.NOT_FOUND
# We verify that tus_file.meta_file.init_from_file() runs correctly after data validation.
# was 500 (Internal Server Error) because tus_file.meta_file.init_from_file() was called before data validation.
def test_upload_without_trailing_slash(self, fxt_task):
"""Test uploading without the trailing slash in the initial URL."""
task = fxt_task
response = self._call_tus_endpoint(
"POST",
BASE_URL + f"/api/tasks/{task.id}/data",
headers={
"Upload-Length": "1000",
"Upload-Metadata": f"filename {base64.b64encode('test_image.jpg'.encode()).decode()}",
"Tus-Resumable": "1.0.0",
},
)
assert response.status == HTTPStatus.CREATED
location = response.headers.get("Location")
assert location is not None
response = self._upload_chunk(location, 0, b"xxxxx")
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == 5
+172
View File
@@ -0,0 +1,172 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from http import HTTPStatus
from typing import Literal
import pytest
from cvat_sdk.api_client import models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.core.helpers import get_paginated_collection
from deepdiff import DeepDiff
from shared.utils.config import make_api_client
from .utils import CollectionSimpleFilterTestBase
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetUsers:
def _test_can_see(
self,
user,
data,
id_: Literal["self"] | int | None = None,
*,
exclude_paths="",
**kwargs,
):
with make_api_client(user) as api_client:
# TODO: refactor into several functions
if id_ == "self":
_, response = api_client.users_api.retrieve_self(**kwargs, _parse_response=False)
assert response.status == HTTPStatus.OK
response_data = json.loads(response.data)
elif id_ is None:
response_data = get_paginated_collection(
api_client.users_api.list_endpoint, return_json=True, **kwargs
)
else:
_, response = api_client.users_api.retrieve(id_, **kwargs, _parse_response=False)
assert response.status == HTTPStatus.OK
response_data = json.loads(response.data)
assert DeepDiff(data, response_data, ignore_order=True, exclude_paths=exclude_paths) == {}
def _test_cannot_see(self, user, id_: Literal["self"] | int | None = None, **kwargs):
with make_api_client(user) as api_client:
# TODO: refactor into several functions
if id_ == "self":
_, response = api_client.users_api.retrieve_self(
**kwargs, _parse_response=False, _check_status=False
)
elif id_ is None:
_, response = api_client.users_api.list(
**kwargs, _parse_response=False, _check_status=False
)
else:
_, response = api_client.users_api.retrieve(
id_, **kwargs, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
def test_admin_can_see_all_others(self, users):
exclude_paths = [f"root[{i}]['last_login']" for i in range(len(users))]
self._test_can_see("admin2", users.raw, exclude_paths=exclude_paths)
def test_everybody_can_see_self(self, users_by_name):
for user, data in users_by_name.items():
self._test_can_see(
user, data, id_="self", exclude_paths=["root['last_login']", "root['key']"]
)
def test_non_members_cannot_see_list_of_members(self):
self._test_cannot_see("user2", org="org1")
def test_non_admin_cannot_see_others(self, users):
non_admins = (v for v in users if not v["is_superuser"])
user = next(non_admins)["username"]
user_id = next(non_admins)["id"]
self._test_cannot_see(user, id_=user_id)
def test_all_members_can_see_list_of_members(self, find_users, users):
org_members = [user["username"] for user in find_users(org=1)]
available_fields = ["url", "id", "username", "first_name", "last_name"]
data = [
dict(filter(lambda row: row[0] in available_fields, user.items()))
for user in users
if user["username"] in org_members
]
for member in org_members:
self._test_can_see(member, data, org="org1")
class TestUsersListFilters(CollectionSimpleFilterTestBase):
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, users):
self.user = admin_user
self.samples = users
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.users_api.list_endpoint
@pytest.mark.parametrize(
"field",
("is_active", "username"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchUsers:
def test_can_update_own_username(self, find_users, admin_user):
db_user = find_users(privilege="user")[0]
old_username = db_user["username"]
new_username = f"{old_username}_renamed"
with make_api_client(old_username) as api_client:
updated_user, response = api_client.users_api.partial_update(
db_user["id"],
patched_user_request=models.PatchedUserRequest(username=new_username),
)
assert response.status == HTTPStatus.OK
assert updated_user.username == new_username
with make_api_client(admin_user) as api_client:
user, response = api_client.users_api.retrieve(db_user["id"])
assert response.status == HTTPStatus.OK
assert user.id == db_user["id"]
assert user.username == new_username
def test_cannot_update_username_to_invalid_value(self, find_users):
user = find_users(privilege="user")[0]
with make_api_client(user["username"]) as api_client:
# Use a raw API call because the generated SDK model validates username
# format client-side and would reject this value before the server sees it.
_, response = api_client.call_api(
f"/api/users/{user['id']}",
method="PATCH",
body={"username": "bad user"},
header_params={"Content-Type": "application/json"},
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.BAD_REQUEST
assert "username" in json.loads(response.data)
def test_non_admin_cannot_update_another_user_username(self, find_users):
user = find_users(privilege="user")[0]
another_user = find_users(privilege="user", exclude_username=user["username"])[0]
with make_api_client(user["username"]) as api_client:
_, response = api_client.users_api.partial_update(
another_user["id"],
patched_user_request=models.PatchedUserRequest(
username=f'{another_user["username"]}_renamed',
),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,829 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from http import HTTPStatus
from time import sleep, time
import pytest
from deepdiff import DeepDiff
from shared.fixtures.data import Container
from shared.fixtures.init import CVAT_ROOT_DIR
from shared.utils.config import delete_method, get_method, patch_method, post_method
from .utils import export_task_backup, export_task_dataset
# Testing webhook functionality:
# - webhook_receiver container receive post request and return responses with the same body
# - CVAT save response body for each delivery
#
# So idea of this testing system is quite simple:
# 1) trigger some webhook
# 2) check that webhook is sent by checking value of `response` field for the last delivery of this webhook
# https://docs.pytest.org/en/7.1.x/example/markers.html#marking-whole-classes-or-modules
pytestmark = [pytest.mark.with_external_services]
def _read_receiver_env():
env_data = {}
with open(CVAT_ROOT_DIR / "tests/python/webhook_receiver/.env", "r") as f:
for line in f:
name, value = tuple(line.strip().split("="))
env_data[name] = value
return env_data
def target_url():
env_data = _read_receiver_env()
return (
f'http://{env_data["SERVER_HOST"]}:{env_data["SERVER_PORT"]}/{env_data["PAYLOAD_ENDPOINT"]}'
)
def webhook_spec(events, project_id=None, webhook_type="organization"):
# Django URL field doesn't allow to use http://webhooks:2020/payload (using alias)
# So we forced to use ip address of webhook receiver container
return {
"target_url": target_url(),
"content_type": "application/json",
"enable_ssl": False,
"events": events,
"is_active": True,
"project_id": project_id,
"type": webhook_type,
}
def create_webhook(events, webhook_type, project_id=None, org_id=""):
assert (webhook_type == "project" and project_id is not None) or (
webhook_type == "organization" and org_id
)
response = post_method(
"admin1", "webhooks", webhook_spec(events, project_id, webhook_type), org_id=org_id
)
assert response.status_code == HTTPStatus.CREATED
return response.json()
def get_deliveries(webhook_id, expected_count=1, *, timeout: int = 60):
start_time = time()
delivery_response = {}
while True:
response = get_method("admin1", f"webhooks/{webhook_id}/deliveries")
assert response.status_code == HTTPStatus.OK
deliveries = response.json()
if deliveries["count"] == expected_count:
raw_deliver_response = deliveries["results"][0]["response"]
delivery_response = json.loads(raw_deliver_response) if raw_deliver_response else {}
break
if time() - start_time > timeout:
raise TimeoutError("Failed to get deliveries within the specified time interval")
sleep(1)
return deliveries, delivery_response
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookProjectEvents:
def test_webhook_update_project_name(self):
response = post_method("admin1", "projects", {"name": "project"})
assert response.status_code == HTTPStatus.CREATED
project = response.json()
events = ["update:project"]
webhook = create_webhook(events, "project", project_id=project["id"])
patch_data = {"name": "new_project_name"}
response = patch_method("admin1", f"projects/{project['id']}", patch_data)
assert response.status_code == HTTPStatus.OK
response = get_method("admin1", f"webhooks/{webhook['id']}/deliveries")
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
assert payload["event"] == events[0]
assert payload["sender"]["username"] == "admin1"
assert payload["before_update"]["name"] == project["name"]
project.update(patch_data)
assert (
DeepDiff(
payload["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_webhook_create_and_delete_project_in_organization(self, organizations):
org_id = list(organizations)[0]["id"]
events = ["create:project", "delete:project"]
webhook = create_webhook(events, "organization", org_id=org_id)
response = post_method("admin1", "projects", {"name": "project_name"}, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
project = response.json()
deliveries, create_payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
response = delete_method("admin1", f"projects/{project['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, delete_payload = get_deliveries(webhook["id"], 2)
assert deliveries["count"] == 2
assert create_payload["event"] == "create:project"
assert delete_payload["event"] == "delete:project"
assert (
DeepDiff(
create_payload["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
assert (
DeepDiff(
delete_payload["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookIntersection:
# Test case description:
# few webhooks are triggered by the same event
# In this case we need to check that CVAT will sent
# the right number of payloads to the target url
def test_project_and_organization_webhooks_intersection(self, organizations):
org_id = list(organizations)[0]["id"]
post_data = {"name": "project_name"}
response = post_method("admin1", "projects", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
events = ["update:project"]
project_id = response.json()["id"]
webhook_id_1 = create_webhook(events, "organization", org_id=org_id)["id"]
webhook_id_2 = create_webhook(events, "project", project_id=project_id, org_id=org_id)["id"]
patch_data = {"name": "new_project_name"}
response = patch_method("admin1", f"projects/{project_id}", patch_data)
assert response.status_code == HTTPStatus.OK
deliveries_1, payload_1 = get_deliveries(webhook_id_1)
deliveries_2, payload_2 = get_deliveries(webhook_id_2)
assert deliveries_1["count"] == deliveries_2["count"] == 1
assert payload_1["project"]["name"] == payload_2["project"]["name"] == patch_data["name"]
assert (
payload_1["before_update"]["name"]
== payload_2["before_update"]["name"]
== post_data["name"]
)
assert payload_1["webhook_id"] == webhook_id_1
assert payload_2["webhook_id"] == webhook_id_2
assert deliveries_1["results"][0]["webhook_id"] == webhook_id_1
assert deliveries_2["results"][0]["webhook_id"] == webhook_id_2
def test_two_project_webhooks_intersection(self):
post_data = {"name": "project_name"}
response = post_method("admin1", "projects", post_data)
assert response.status_code == HTTPStatus.CREATED
project_id = response.json()["id"]
events_1 = ["create:task", "update:issue"]
events_2 = ["create:task", "create:issue"]
webhook_id_1 = create_webhook(events_1, "project", project_id=project_id)["id"]
webhook_id_2 = create_webhook(events_2, "project", project_id=project_id)["id"]
post_data = {"name": "project_name", "project_id": project_id}
response = post_method("admin1", "tasks", post_data)
assert response.status_code == HTTPStatus.CREATED
deliveries_1, payload_1 = get_deliveries(webhook_id_1)
deliveries_2, payload_2 = get_deliveries(webhook_id_2)
assert deliveries_1["count"] == deliveries_2["count"] == 1
assert payload_1["event"] == payload_2["event"] == "create:task"
assert payload_1["task"]["name"] == payload_2["task"]["name"] == post_data["name"]
assert payload_1["webhook_id"] == webhook_id_1
assert payload_2["webhook_id"] == webhook_id_2
def test_two_organization_webhook_intersection(self, organizations):
org_id = list(organizations)[0]["id"]
events_1 = ["create:project", "update:membership"]
events_2 = ["create:project", "update:job"]
webhook_id_1 = create_webhook(events_1, "organization", org_id=org_id)["id"]
webhook_id_2 = create_webhook(events_2, "organization", org_id=org_id)["id"]
post_data = {"name": "project_name"}
response = post_method("admin1", "projects", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
project = response.json()
deliveries_1, payload_1 = get_deliveries(webhook_id_1)
deliveries_2, payload_2 = get_deliveries(webhook_id_2)
assert deliveries_1["count"] == deliveries_2["count"] == 1
assert payload_1["webhook_id"] == webhook_id_1
assert payload_2["webhook_id"] == webhook_id_2
assert (
DeepDiff(
payload_1["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
assert (
DeepDiff(
payload_2["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookTaskEvents:
def test_webhook_update_task_assignee(self, users, tasks):
task_id, project_id = next(
(task["id"], task["project_id"])
for task in tasks
if task["project_id"] is not None
and task["organization"] is None
and task["assignee"] is not None
)
assignee_id = next(
user["id"] for user in users if user["id"] != tasks[task_id]["assignee"]["id"]
)
webhook_id = create_webhook(["update:task"], "project", project_id=project_id)["id"]
patch_data = {"assignee_id": assignee_id}
response = patch_method("admin1", f"tasks/{task_id}", patch_data)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id=webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["assignee"]["id"] == tasks[task_id]["assignee"]["id"]
assert payload["task"]["assignee"]["id"] == assignee_id
def test_webhook_create_and_delete_task(self, organizations):
org_id = list(organizations)[0]["id"]
events = ["create:task", "delete:task"]
webhook = create_webhook(events, "organization", org_id=org_id)
post_data = {"name": "task_name", "labels": [{"name": "label_0"}]}
response = post_method("admin1", "tasks", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
task = response.json()
deliveries, create_payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
response = delete_method("admin1", f"tasks/{task['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, delete_payload = get_deliveries(webhook["id"], 2)
assert deliveries["count"] == 2
assert create_payload["event"] == "create:task"
assert delete_payload["event"] == "delete:task"
# These values cannot be computed if the task has no data
assert create_payload["task"]["jobs"]["completed"] is None
assert create_payload["task"]["jobs"]["validation"] is None
assert task["jobs"]["completed"] == 0
assert task["jobs"]["validation"] == 0
assert delete_payload["task"]["jobs"]["completed"] == 0
assert delete_payload["task"]["jobs"]["validation"] == 0
assert (
DeepDiff(
create_payload["task"],
task,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['jobs']", "root['labels']"],
)
== {}
)
assert (
DeepDiff(
delete_payload["task"],
task,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['jobs']", "root['labels']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookJobEvents:
def test_webhook_update_job_assignee(self, jobs, tasks, users):
job = next(
job
for job in jobs
if job["assignee"] is None and tasks[job["task_id"]]["organization"] is not None
)
org_id = tasks[job["task_id"]]["organization"]
webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"]
patch_data = {"assignee": list(users)[0]["id"]}
response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["assignee"] is None
assert payload["job"]["assignee"]["id"] == patch_data["assignee"]
def test_webhook_update_job_stage(self, jobs, tasks):
stages = {"annotation", "validation", "acceptance"}
job = next(job for job in jobs if tasks[job["task_id"]]["organization"] is not None)
org_id = tasks[job["task_id"]]["organization"]
webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"]
patch_data = {"stage": (stages - {job["stage"]}).pop()}
response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["stage"] == job["stage"]
assert payload["job"]["stage"] == patch_data["stage"]
def test_webhook_update_job_state(self, jobs, tasks):
states = {"new", "in progress", "rejected", "completed"}
job = next(
job
for job in jobs
if tasks[job["task_id"]]["organization"] is not None and job["state"] == "in progress"
)
org_id = tasks[job["task_id"]]["organization"]
webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"]
patch_data = {"state": (states - {job["state"]}).pop()}
response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["state"] == job["state"]
assert payload["job"]["state"] == patch_data["state"]
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookIssueEvents:
def test_webhook_update_issue_resolved(self, issues, jobs, tasks):
issue = next(
issue
for issue in issues
if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None
)
org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"]
webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"]
patch_data = {"resolved": not issue["resolved"]}
response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["resolved"] == issue["resolved"]
assert payload["issue"]["resolved"] == patch_data["resolved"]
def test_webhook_update_issue_position(self, issues, jobs, tasks):
issue = next(
issue
for issue in issues
if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None
)
org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"]
webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"]
patch_data = {"position": [0, 1, 2, 3]}
response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["position"] == issue["position"]
assert payload["issue"]["position"] == patch_data["position"]
@pytest.mark.parametrize("org_id", (2,))
def test_webhook_create_and_delete_issue(self, org_id: int, jobs, tasks):
job_id = next(job["id"] for job in jobs if tasks[job["task_id"]]["organization"] == org_id)
events = ["create:issue", "delete:issue"]
webhook = create_webhook(events, "organization", org_id=org_id)
post_data = {"frame": 0, "position": [0, 1, 2, 3], "job": job_id, "message": "issue_msg"}
response = post_method("admin1", "issues", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
issue = response.json()
deliveries, create_payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
response = delete_method("admin1", f"issues/{issue['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, delete_payload = get_deliveries(webhook["id"], 2)
assert deliveries["count"] == 2
assert create_payload["event"] == "create:issue"
assert delete_payload["event"] == "delete:issue"
assert (
DeepDiff(
create_payload["issue"],
issue,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['comments']"],
)
== {}
)
assert (
DeepDiff(
delete_payload["issue"],
issue,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['comments']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookMembershipEvents:
def test_webhook_update_membership_role(self, memberships):
roles = {"worker", "supervisor", "maintainer"}
membership = next(membership for membership in memberships if membership["role"] != "owner")
org_id = membership["organization"]
webhook_id = create_webhook(["update:membership"], "organization", org_id=org_id)["id"]
patch_data = {"role": (roles - {membership["role"]}).pop()}
response = patch_method(
"admin1", f"memberships/{membership['id']}", patch_data, org_id=org_id
)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["role"] == membership["role"]
assert payload["membership"]["role"] == patch_data["role"]
def test_webhook_delete_membership(self, memberships):
membership = next(membership for membership in memberships if membership["role"] != "owner")
org_id = membership["organization"]
webhook_id = create_webhook(["delete:membership"], "organization", org_id=org_id)["id"]
response = delete_method("admin1", f"memberships/{membership['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert (
DeepDiff(
payload["membership"],
membership,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['invitation']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookOrganizationEvents:
def test_webhook_update_organization_name(self, organizations):
org_id = list(organizations)[0]["id"]
webhook_id = create_webhook(["update:organization"], "organization", org_id=org_id)["id"]
patch_data = {"name": "new_org_name"}
patch_method("admin1", f"organizations/{org_id}", patch_data, org_id=org_id)
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["name"] == organizations[org_id]["name"]
assert payload["organization"]["name"] == patch_data["name"]
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookCommentEvents:
def test_webhook_update_comment_message(self, comments, issues, jobs, tasks):
org_comments = list(
(comment, tasks[jobs[issues[comment["issue"]]["job"]]["task_id"]]["organization"])
for comment in comments
)
comment, org_id = next(
((comment, org_id) for comment, org_id in org_comments if org_id is not None)
)
webhook_id = create_webhook(["update:comment"], "organization", org_id=org_id)["id"]
patch_data = {"message": "new comment message"}
response = patch_method("admin1", f"comments/{comment['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["message"] == comment["message"]
comment.update(patch_data)
assert (
DeepDiff(
payload["comment"],
comment,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_webhook_create_and_delete_comment(self, issues, jobs, tasks):
issue = next(
issue
for issue in issues
if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None
)
org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"]
events = ["create:comment", "delete:comment"]
webhook_id = create_webhook(events, "organization", org_id=org_id)["id"]
post_data = {"issue": issue["id"], "message": "new comment message"}
response = post_method("admin1", "comments", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
create_deliveries, create_payload = get_deliveries(webhook_id)
comment_id = response.json()["id"]
response = delete_method("admin1", f"comments/{comment_id}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
delete_deliveries, delete_payload = get_deliveries(webhook_id, 2)
assert create_deliveries["count"] == 1
assert delete_deliveries["count"] == 2
assert create_payload["event"] == "create:comment"
assert delete_payload["event"] == "delete:comment"
assert (
create_payload["comment"]["message"]
== delete_payload["comment"]["message"]
== post_data["message"]
)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetWebhookDeliveries:
def test_not_project_staff_cannot_get_webhook(self, projects, users):
user, project = next(
(user, project)
for user in users
if "user" in user["groups"]
for project in projects
if project["owner"]["id"] != user["id"]
)
webhook = create_webhook(["create:task"], "project", project_id=project["id"])
owner = next(user for user in users if user["id"] == project["owner"]["id"])
response = post_method(owner["username"], f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.OK
delivery_id = response.json()["id"]
response = get_method(user["username"], f"webhooks/{webhook['id']}/deliveries")
assert response.status_code == HTTPStatus.FORBIDDEN
response = get_method(
user["username"], f"webhooks/{webhook['id']}/deliveries/{delivery_id}"
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookPing:
def test_ping_webhook(self, projects):
project_id = list(projects)[0]["id"]
webhook = create_webhook(["create:task"], "project", project_id=project_id)
response = post_method("admin1", f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
assert (
DeepDiff(
payload["webhook"],
webhook,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_not_project_staff_cannot_ping(self, projects, users):
user, project = next(
(user, project)
for user in users
if "user" in user["groups"]
for project in projects
if project["owner"]["id"] != user["id"]
)
webhook = create_webhook(["create:task"], "project", project_id=project["id"])
response = post_method(user["username"], f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookRedelivery:
def test_webhook_redelivery(self, projects):
project = list(projects)[0]
webhook_id = create_webhook(["update:project"], "project", project_id=project["id"])["id"]
patch_data = {"name": "new_project_name"}
response = patch_method("admin1", f"projects/{project['id']}", patch_data)
assert response.status_code == HTTPStatus.OK
deliveries_1, payload_1 = get_deliveries(webhook_id)
delivery_id = deliveries_1["results"][0]["id"]
response = post_method(
"admin1", f"webhooks/{webhook_id}/deliveries/{delivery_id}/redelivery", {}
)
assert response.status_code == HTTPStatus.OK
deliveries_2, payload_2 = get_deliveries(webhook_id, 2)
assert deliveries_1["count"] == 1
assert deliveries_2["count"] == 2
assert deliveries_1["results"][0]["redelivery"] is False
assert deliveries_2["results"][0]["redelivery"] is True
project.update(patch_data)
assert (
DeepDiff(
payload_1["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
assert (
DeepDiff(
payload_2["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_not_project_staff_cannot_redeliver(self, projects, users):
user, project = next(
(user, project)
for user in users
if "user" in user["groups"]
for project in projects
if project["owner"]["id"] != user["id"]
)
webhook = create_webhook(["create:task"], "project", project_id=project["id"])
owner = next(user for user in users if user["id"] == project["owner"]["id"])
response = post_method(owner["username"], f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.OK
delivery_id = response.json()["id"]
response = post_method(
user["username"], f"webhooks/{webhook['id']}/deliveries/{delivery_id}/redelivery", {}
)
assert response.status_code == HTTPStatus.FORBIDDEN
def _task_with_data_in_org(tasks: Container) -> dict:
return next(
t
for t in tasks
if t["mode"] in ("annotation", "interpolation")
and not t["validation_mode"]
and t["organization"] is not None
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookExportEvents:
def test_webhook_create_export_for_task(self, tasks: Container) -> None:
task = _task_with_data_in_org(tasks)
webhook_id = create_webhook(
events=["create:export"], webhook_type="organization", org_id=task["organization"]
)["id"]
export_task_dataset("admin1", id=task["id"], save_images=False, download_result=False)
_, payload = get_deliveries(webhook_id)
assert payload["event"] == "create:export"
assert payload["status"] == "succeeded"
assert payload["target"] == "task"
assert payload["target_id"] == task["id"]
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookBackupEvents:
def test_webhook_create_backup_for_task(self, tasks: Container) -> None:
task = _task_with_data_in_org(tasks)
webhook_id = create_webhook(
events=["create:backup"], webhook_type="organization", org_id=task["organization"]
)["id"]
export_task_backup("admin1", id=task["id"], download_result=False)
_, payload = get_deliveries(webhook_id)
assert payload["event"] == "create:backup"
assert payload["status"] == "succeeded"
assert payload["target"] == "task"
assert payload["target_id"] == task["id"]
+40
View File
@@ -0,0 +1,40 @@
# Copyright (C) 2025 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from collections.abc import Callable
from contextlib import closing
from typing import ClassVar
import attrs
from PIL import Image
from shared.tasks.base import TaskSpecBase
from shared.tasks.enums import SourceDataType
from shared.utils.helpers import read_video_file
@attrs.define
class VideoTaskSpec(TaskSpecBase):
source_data_type: ClassVar[SourceDataType] = SourceDataType.video
_get_video_file: Callable[[], io.IOBase] = attrs.field(kw_only=True)
def read_frame(self, i: int) -> Image.Image:
with closing(read_video_file(self._get_video_file())) as reader:
for _ in range(i + 1):
frame = next(reader)
return frame
@attrs.define
class ImagesTaskSpec(TaskSpecBase):
source_data_type: ClassVar[SourceDataType] = SourceDataType.images
_get_frame: Callable[[int], bytes] = attrs.field(kw_only=True)
def read_frame(self, i: int) -> Image.Image:
return Image.open(io.BytesIO(self._get_frame(i)))
+660
View File
@@ -0,0 +1,660 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from abc import ABCMeta, abstractmethod
from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence
from copy import deepcopy
from http import HTTPStatus
from io import BytesIO
from time import sleep
from typing import Any, TypeAlias, TypeVar
import requests
from cvat_sdk.api_client import apis, models
from cvat_sdk.api_client.api.jobs_api import JobsApi
from cvat_sdk.api_client.api.projects_api import ProjectsApi
from cvat_sdk.api_client.api.tasks_api import TasksApi
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.api_client.exceptions import ForbiddenException
from cvat_sdk.core.helpers import get_paginated_collection
from deepdiff import DeepDiff
from urllib3 import HTTPResponse
from shared.utils.config import USER_PASS, make_api_client, post_method
DEFAULT_RETRIES = 50
DEFAULT_INTERVAL = 0.1
def initialize_export(endpoint: Endpoint, *, expect_forbidden: bool = False, **kwargs) -> str:
_, response = endpoint.call_with_http_info(**kwargs, _parse_response=False, _check_status=False)
if expect_forbidden:
assert (
response.status == HTTPStatus.FORBIDDEN
), f"Request should be forbidden, status: {response.status}"
raise ForbiddenException()
assert response.status == HTTPStatus.ACCEPTED, (f"Status: {response.status}", response.data)
# define background request ID returned in the server response
rq_id = json.loads(response.data).get("rq_id")
assert rq_id, "The rq_id parameter was not found in the server response"
return rq_id
def wait_background_request(
api_client: ApiClient,
rq_id: str,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
) -> tuple[models.Request, HTTPResponse]:
for _ in range(max_retries):
background_request, response = api_client.requests_api.retrieve(rq_id)
assert response.status == HTTPStatus.OK
if background_request.status.value == "finished":
return background_request, response
assert (
background_request.status.value != "failed"
), f"Background request failed with message: {background_request.message}"
sleep(interval)
assert False, (
f"Export process was not finished within allowed time ({interval * max_retries}, sec). "
+ f"Last status was: {background_request.status.value}"
)
def wait_and_download_v2(
api_client: ApiClient,
rq_id: str,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
) -> bytes:
background_request, _ = wait_background_request(
api_client, rq_id, max_retries=max_retries, interval=interval
)
# return downloaded file in case of local downloading
assert background_request.result_url
headers = api_client.get_common_headers()
query_params = []
api_client.update_params_for_auth(headers=headers, queries=query_params)
assert not query_params # query auth is not expected
response = requests.get(background_request.result_url, headers=headers)
assert response.status_code == HTTPStatus.OK, f"Status: {response.status_code}"
return response.content
def export_v2(
endpoint: Endpoint,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
expect_forbidden: bool = False,
wait_result: bool = True,
download_result: bool = True,
**kwargs,
) -> bytes | str:
"""Export datasets|annotations|backups using the second version of export API
Args:
endpoint (Endpoint): Export endpoint, will be called only to initialize export process
max_retries (int, optional): Number of retries when checking process status. Defaults to 30.
interval (float, optional): Interval in seconds between retries. Defaults to 0.1.
expect_forbidden (bool, optional): Should export request be forbidden or not. Defaults to False.
download_result (bool, optional): Download exported file. Defaults to True.
Returns:
bytes: The content of the file if downloaded locally.
str: If `wait_result` or `download_result` were False.
"""
# initialize background process and ensure that the first request returns 403 code if request should be forbidden
rq_id = initialize_export(endpoint, expect_forbidden=expect_forbidden, **kwargs)
if not wait_result:
return rq_id
# check status of background process
if download_result:
return wait_and_download_v2(
endpoint.api_client,
rq_id,
max_retries=max_retries,
interval=interval,
)
background_request, _ = wait_background_request(
endpoint.api_client, rq_id, max_retries=max_retries, interval=interval
)
return background_request.id
def export_dataset(
api: ProjectsApi | TasksApi | JobsApi,
*,
save_images: bool,
max_retries: int = 300,
interval: float = DEFAULT_INTERVAL,
format: str = "CVAT for images 1.1", # pylint: disable=redefined-builtin
**kwargs,
) -> bytes | None:
return export_v2(
api.create_dataset_export_endpoint,
max_retries=max_retries,
interval=interval,
save_images=save_images,
format=format,
**kwargs,
)
# FUTURE-TODO: support username: optional, api_client: optional
# tODO: make func signature more userfrendly
def export_project_dataset(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_dataset(api_client.projects_api, *args, **kwargs)
def export_task_dataset(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_dataset(api_client.tasks_api, *args, **kwargs)
def export_job_dataset(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_dataset(api_client.jobs_api, *args, **kwargs)
def export_backup(
api: ProjectsApi | TasksApi,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
**kwargs,
) -> bytes | None:
endpoint = api.create_backup_export_endpoint
return export_v2(endpoint, max_retries=max_retries, interval=interval, **kwargs)
def export_project_backup(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_backup(api_client.projects_api, *args, **kwargs)
def export_task_backup(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_backup(api_client.tasks_api, *args, **kwargs)
def import_resource(
endpoint: Endpoint,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
expect_forbidden: bool = False,
wait_result: bool = True,
**kwargs,
) -> models.Request | None:
# initialize background process and ensure that the first request returns 403 code if request should be forbidden
_, response = endpoint.call_with_http_info(
**kwargs,
_parse_response=False,
_check_status=False,
_content_type="multipart/form-data",
)
if expect_forbidden:
assert response.status == HTTPStatus.FORBIDDEN, "Request should be forbidden"
raise ForbiddenException()
assert response.status == HTTPStatus.ACCEPTED
if not wait_result:
return None
# define background request ID returned in the server response
rq_id = json.loads(response.data).get("rq_id")
assert rq_id, "The rq_id parameter was not found in the server response"
# check status of background process
for _ in range(max_retries):
background_request, response = endpoint.api_client.requests_api.retrieve(rq_id)
assert response.status == HTTPStatus.OK
if background_request.status.value in (
models.RequestStatus.allowed_values[("value",)]["FINISHED"],
models.RequestStatus.allowed_values[("value",)]["FAILED"],
):
break
sleep(interval)
else:
assert False, (
f"Import process was not finished within allowed time ({interval * max_retries}, sec). "
+ f"Last status was: {background_request.status.value}"
)
return background_request
def import_backup(
api: ProjectsApi | TasksApi,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
**kwargs,
):
endpoint = api.create_backup_endpoint
return import_resource(endpoint, max_retries=max_retries, interval=interval, **kwargs)
def import_project_backup(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_backup(
api_client.projects_api, project_file_request={"project_file": file_content}, **kwargs
)
def import_task_backup(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_backup(
api_client.tasks_api, task_file_request={"task_file": file_content}, **kwargs
)
def import_project_dataset(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_resource(
api_client.projects_api.create_dataset_endpoint,
dataset_file_request={"dataset_file": file_content},
**kwargs,
)
def import_task_annotations(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_resource(
api_client.tasks_api.create_annotations_endpoint,
annotation_file_request={"annotation_file": file_content},
**kwargs,
)
def import_job_annotations(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_resource(
api_client.jobs_api.create_annotations_endpoint,
annotation_file_request={"annotation_file": file_content},
**kwargs,
)
FieldPath: TypeAlias = Sequence[str | Callable]
class CollectionSimpleFilterTestBase(metaclass=ABCMeta):
# These fields need to be defined in the subclass
user: str
samples: list[dict[str, Any]]
field_lookups: dict[str, FieldPath] = None
cmp_ignore_keys: list[str] = ["updated_date"]
@abstractmethod
def _get_endpoint(self, api_client: ApiClient) -> Endpoint: ...
def _retrieve_collection(self, **kwargs) -> list:
kwargs["return_json"] = True
with make_api_client(self.user) as api_client:
return get_paginated_collection(self._get_endpoint(api_client), **kwargs)
@classmethod
def _get_field(cls, d: dict[str, Any], path: str | FieldPath) -> Any | None:
assert path
for key in path:
if isinstance(d, dict):
assert isinstance(key, str)
d = d.get(key)
else:
if callable(key):
assert isinstance(d, str)
d = key(d)
else:
d = None
return d
def _map_field(self, name: str) -> FieldPath:
return (self.field_lookups or {}).get(name, [name])
@classmethod
def _find_valid_field_value(
cls, samples: Iterator[dict[str, Any]], field_path: FieldPath
) -> Any:
value = None
for sample in samples:
value = cls._get_field(sample, field_path)
if value:
break
assert value, f"Failed to find a sample for the '{'.'.join(field_path)}' field"
return value
def _get_field_samples(self, field: str) -> tuple[Any, list[dict[str, Any]]]:
field_path = self._map_field(field)
field_value = self._find_valid_field_value(self.samples, field_path)
gt_objects = filter(lambda p: field_value == self._get_field(p, field_path), self.samples)
return field_value, gt_objects
def _compare_results(self, gt_objects, received_objects):
if self.cmp_ignore_keys:
ignore_keys = [f"root['{k}']" for k in self.cmp_ignore_keys]
else:
ignore_keys = None
diff = DeepDiff(
list(gt_objects),
received_objects,
exclude_paths=ignore_keys,
ignore_order=True,
)
assert diff == {}, diff
def _test_can_use_simple_filter_for_object_list(
self, field: str, field_values: list[Any] | None = None
):
gt_objects = []
field_path = self._map_field(field)
if not field_values:
value, gt_objects = self._get_field_samples(field)
field_values = [value]
are_gt_objects_initialized = bool(gt_objects)
for value in field_values:
if not are_gt_objects_initialized:
gt_objects = [
sample
for sample in self.samples
if value == self._get_field(sample, field_path)
]
received_items = self._retrieve_collection(**{field: value})
self._compare_results(gt_objects, received_items)
def get_attrs(obj: Any, attributes: Sequence[str]) -> tuple[Any, ...]:
"""Returns 1 or more object attributes as a tuple"""
return (getattr(obj, attr) for attr in attributes)
def build_exclude_paths_expr(ignore_fields: Iterator[str]) -> list[str]:
exclude_expr_parts = []
for key in ignore_fields:
if "." in key:
key_parts = key.split(".")
expr = r"root\['{}'\]".format(key_parts[0])
expr += "".join(r"\[.*\]\['{}'\]".format(part) for part in key_parts[1:])
else:
expr = r"root\['{}'\]".format(key)
exclude_expr_parts.append(expr)
return exclude_expr_parts
def wait_until_task_is_created(api: apis.RequestsApi, rq_id: str) -> models.Request:
for _ in range(100):
request_details, _ = api.retrieve(rq_id)
if request_details.status.value in ("finished", "failed"):
return request_details
sleep(1)
raise Exception("Cannot create task")
def create_task(username, spec, data, content_type="application/json", **kwargs):
with make_api_client(username) as api_client:
task, response_ = api_client.tasks_api.create(spec, **kwargs)
assert response_.status == HTTPStatus.CREATED
sent_upload_start = False
data_kwargs = (kwargs or {}).copy()
data_kwargs.pop("org", None)
data_kwargs.pop("org_id", None)
if data.get("client_files") and "json" in content_type:
_, response = api_client.tasks_api.create_data(
task.id,
data_request=models.DataRequest(image_quality=data["image_quality"]),
upload_start=True,
_content_type=content_type,
**data_kwargs,
)
assert response.status == HTTPStatus.ACCEPTED
sent_upload_start = True
# Can't encode binary files in json
_, response = api_client.tasks_api.create_data(
task.id,
data_request=models.DataRequest(
client_files=data["client_files"],
image_quality=data["image_quality"],
),
upload_multiple=True,
_content_type="multipart/form-data",
**data_kwargs,
)
assert response.status == HTTPStatus.OK
data = data.copy()
del data["client_files"]
last_kwargs = {}
if sent_upload_start:
last_kwargs["upload_finish"] = True
result, response = api_client.tasks_api.create_data(
task.id,
data_request=deepcopy(data),
_content_type=content_type,
**data_kwargs,
**last_kwargs,
)
assert response.status == HTTPStatus.ACCEPTED
request_details = wait_until_task_is_created(api_client.requests_api, result.rq_id)
assert request_details.status.value == "finished", request_details.message
return task.id, response_.headers.get("X-Request-Id")
def compare_annotations(
a: dict,
b: dict,
*,
ignore_spec_ids: bool = False,
ignore_source: bool = False,
ignore_score: bool = False,
) -> dict:
def _exclude_cb(obj, path: str):
# ignore track elements which do not have shapes
split_path = path.rsplit("['elements']", maxsplit=1)
if len(split_path) == 2:
if split_path[1].count("[") == 1 and not obj["shapes"]:
return True
return path.endswith("['elements']") and not obj
excluded_paths = [
r"root\['version|updated_date'\]",
r"root(\['\w+'\]\[\d+\])+\['id'\]",
]
if ignore_score:
excluded_paths += [
r"root(\['\w+'\]\[\d+\])+\['score'\]",
]
if ignore_source:
excluded_paths += [
r"root(\['\w+'\]\[\d+\])+\['source'\]",
]
if ignore_spec_ids:
excluded_paths += [
r"root(\['\w+'\]\[\d+\])+\['label_id'\]",
r"root(\['\w+'\]\[\d+\])+\['attributes'\]\[\d+\]\['spec_id'\]",
]
return DeepDiff(
a,
b,
ignore_order=True,
significant_digits=2, # annotations are stored with 2 decimal digit precision
exclude_obj_callback=_exclude_cb,
exclude_regex_paths=excluded_paths,
)
DATUMARO_FORMAT_FOR_DIMENSION = {
"2d": "Datumaro 1.0",
"3d": "Datumaro 3D 1.0",
}
def calc_end_frame(start_frame: int, stop_frame: int, frame_step: int) -> int:
return stop_frame - ((stop_frame - start_frame) % frame_step) + frame_step
_T = TypeVar("_T")
_T2 = TypeVar("_T2")
def unique(
it: Iterator[_T] | Iterable[_T], *, key: Callable[[_T], Hashable] = None
) -> Iterable[_T]:
return {key(v): v for v in it}.values()
def register_new_user(username: str) -> dict[str, Any]:
response = post_method(
"admin1",
"auth/register",
data={
"username": username,
"password1": USER_PASS,
"password2": USER_PASS,
"email": f"{username}@email.com",
},
)
assert response.status_code == HTTPStatus.CREATED
return response.json()
def invite_user_to_org(
user_email: str,
org_id: int,
role: str,
):
with make_api_client("admin1") as api_client:
invitation, _ = api_client.invitations_api.create(
models.InvitationWriteRequest(
role=role,
email=user_email,
),
org_id=org_id,
)
return invitation
def get_cloud_storage_content(
username: str,
cloud_storage_id: int,
*,
manifest: str | None = None,
prefix: str | None = None,
) -> list[str]:
kwargs = {}
if manifest is not None:
kwargs["manifest_path"] = manifest
if prefix is not None:
kwargs["prefix"] = prefix
prefix = (prefix or "").rstrip("/") + "/"
with make_api_client(username) as api_client:
data, _ = api_client.cloudstorages_api.retrieve_content_v2(cloud_storage_id, **kwargs)
return [
f"{prefix}{f['name']}{'/' if str(f['type']) == 'DIR' else ''}" for f in data["content"]
]
def export_events(
api_client: ApiClient,
*,
api_version: int,
max_retries: int = 100,
interval: float = 0.1,
**kwargs,
) -> bytes | None:
if api_version == 1:
endpoint = api_client.events_api.list_endpoint
query_id = ""
for _ in range(max_retries):
_, response = endpoint.call_with_http_info(
**kwargs, query_id=query_id, _parse_response=False
)
if response.status == HTTPStatus.CREATED:
break
assert response.status == HTTPStatus.ACCEPTED
if not query_id:
response_json = json.loads(response.data)
query_id = response_json["query_id"]
sleep(interval)
assert response.status == HTTPStatus.CREATED
_, response = endpoint.call_with_http_info(
**kwargs, query_id=query_id, action="download", _parse_response=False
)
assert response.status == HTTPStatus.OK
return response.data
assert api_version == 2
request_id, response = api_client.events_api.create_export(**kwargs, _check_status=False)
assert response.status == HTTPStatus.ACCEPTED
if "location" in kwargs and "cloud_storage_id" in kwargs:
background_request, response = wait_background_request(
api_client, rq_id=request_id.rq_id, max_retries=max_retries, interval=interval
)
assert background_request.result_url is None
return None
return wait_and_download_v2(
api_client, rq_id=request_id.rq_id, max_retries=max_retries, interval=interval
)
def iter_exclude(
it: Iterable[_T], excludes: Iterable[_T2], *, key: Callable[[_T], _T2] | None = None
) -> Iterable[_T]:
excludes = set(excludes)
if not key:
key = lambda v: v
return (v for v in it if key(v) not in excludes)