Files
Maxim Zhiltsov 06dd39feff Move some fields from search to filters (#10569)
### Summary

- Moved most enum-like fields from `search_fields` to `simple_filters`,
and `filter_fields` in viewsets
- Added field sorting for schema

### Motivation and context

Enum-like fields (e.g. `status`, `state`, `role`) were declared in
`search_fields` on several list endpoints. DRF's `SearchFilter` performs
case-insensitive substring matching across `search_fields`, so the
global `?search=` parameter would also match against these enum values,
which is rarely what users want. They are now exposed only via
`simple_filters` (exact match), e.g. `?status=completed`,
`?state=in_progress`, `?role=maintainer`.

In some cases, free-text fields are intentionally excluded from simple
filters, because exact matching is not very useful for them. By this
logic, `project_name` and `task_name` in task and job list endpoints
should be removed as well, but they are kept to avoid breaking changes
without a significant reason, as they're available for quite a long time
already.

The filters are now sorted for schema generation, which helps to avoid
spurious schema diffs.

#### Per-endpoint changes

| Endpoint | Removed from `search_fields` | Added to `simple_filters` |
Added to `filter_fields` |
|---|---|---|---|
| `GET /api/projects` | `status` | — | — |
| `GET /api/tasks` | `status`, `mode`, `dimension`, `validation_mode` |
— | — |
| `GET /api/jobs` | `state`, `stage` | — | — |
| `GET /api/memberships` | `role` | — | — |
| `GET /api/webhooks` | `type` | — | — |
| `GET /api/cloudstorages` | `provider_type`, `credentials_type` | — | —
|
| `GET /api/access_tokens` | — | `read_only` | — |
| `GET /api/invitations` | — | `user_id`, `accepted` | `id` |
| `GET /api/requests` | — | `org_id` | `org`, `org_id` |

For fields removed from `search_fields`: they remain exact-match
filterable as `simple_filters` (e.g. `?status=completed`).
For fields newly in `simple_filters`: they were previously available
only via the `filter` parameter and are now also exact-match filterable.
For fields newly in `filter_fields`: they were previously not available
for filtering.

The following endpoints were touched only for the construction-style
refactor (no field-level behavior change):
`GET /api/issues`, `GET /api/comments`, `GET /api/labels`, `GET
/api/users`, `GET /api/organizations`, `GET /api/consensus_settings`,
`GET /api/quality/conflicts`, `GET /api/quality/reports`, `GET
/api/quality/settings`.

### API response changes

The following list endpoints now return additional fields:

- `GET /api/requests` — `operation.org_id` (integer, nullable). Supports
`?org_id=` filtering.
- `GET /api/invitations` — `accepted` (boolean). Mirrors the existing
`accepted` filters.

### How has this been tested?

Added simple-filter test coverage for fields newly exposed in this PR:

- `tests/python/rest_api/test_access_tokens.py`: `read_only`.
- `tests/python/rest_api/test_invitations.py`: `user_id`, `accepted`.
- `tests/python/rest_api/test_requests.py`: `org`, `org_id`.

Filled in pre-existing simple-filter coverage gaps:

- `tests/python/rest_api/test_jobs.py`: `dimension`, `media_type`,
`mode`, `task_name`, `project_name`.
- `tests/python/rest_api/test_tasks.py`: `media_type`, `project_name`.

`TestRequestsListFilters` was refactored to class-scoped fixtures so the
heavy setup (3 projects + 3 tasks + ~13 RQ requests + the new
org-context project/task/exports) runs once for the whole class instead
of per parametrize case. Wall-clock for `pytest -k
TestRequestsListFilters` on the same hardware:

| | Before | After |
|---|---|---|
| Tests collected | 9 | 10 |
| Total time | 78.4 s | 13.1 s |
| Per-case `call` time | ~5.2 s | ~0.05 s |
| Per-case `setup` time | ~4.3 s every case | ~12.1 s once, then
negligible |

≈6× faster despite covering one more case.

The corrupted-meta test was also extracted out of the class to a
module-level function with `restore_db_per_function` /
`restore_redis_inmem_per_function` decorators since it has no class
dependencies.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:05:00 +03:00

165 lines
6.0 KiB
Python

# 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) == {}