Files
cvat-ai--cvat/tests/python/rest_api/test_requests.py
T
2026-07-13 13:32:23 +08:00

689 lines
25 KiB
Python

# 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,
)