chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Copyright (C) 2022 Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 didn’t 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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) == {}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user