Skip to content

Commit d315d78

Browse files
Allow quality reports to regular users (#8511)
- Allowed quality report computation for tasks to regular users (before this only admins could trigger custom report computations) - Refactored quality control tests --------- Co-authored-by: Maria Khrustaleva <[email protected]>
1 parent 5104fd9 commit d315d78

12 files changed

+529
-418
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### Changed
2+
3+
- \[Server API\] Quality report computation is now allowed to regular users
4+
(<https://github.com/cvat-ai/cvat/pull/8511>)

Diff for: cvat/apps/iam/rules/utils.rego

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ IMPORT_DATASET := "import:dataset"
5858
IMPORT_BACKUP := "import:backup"
5959
EXPORT_BACKUP := "export:backup"
6060
UPDATE_ORG := "update:organization"
61+
VIEW_STATUS := "view:status"
6162
VIEW_VALIDATION_LAYOUT := "view:validation_layout"
6263
UPDATE_VALIDATION_LAYOUT := "update:validation_layout"
6364

Diff for: cvat/apps/quality_control/permissions.py

+60-15
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
from typing import Optional, Union, cast
77

88
from django.conf import settings
9-
from rest_framework.exceptions import ValidationError
9+
from rest_framework.exceptions import PermissionDenied, ValidationError
1010

11-
from cvat.apps.engine.models import Task
11+
from cvat.apps.engine.models import Project, Task
1212
from cvat.apps.engine.permissions import TaskPermission
1313
from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum, get_iam_context
1414

@@ -18,6 +18,7 @@
1818
class QualityReportPermission(OpenPolicyAgentPermission):
1919
obj: Optional[QualityReport]
2020
job_owner_id: Optional[int]
21+
task_id: Optional[int]
2122

2223
class Scopes(StrEnum):
2324
LIST = "list"
@@ -29,7 +30,7 @@ class Scopes(StrEnum):
2930
def create_scope_check_status(cls, request, job_owner_id: int, iam_context=None):
3031
if not iam_context and request:
3132
iam_context = get_iam_context(request, None)
32-
return cls(**iam_context, scope="view:status", job_owner_id=job_owner_id)
33+
return cls(**iam_context, scope=cls.Scopes.VIEW_STATUS, job_owner_id=job_owner_id)
3334

3435
@classmethod
3536
def create_scope_view(cls, request, report: Union[int, QualityReport], iam_context=None):
@@ -59,11 +60,42 @@ def create(cls, request, view, obj, iam_context):
5960
elif scope == Scopes.LIST and isinstance(obj, Task):
6061
permissions.append(TaskPermission.create_scope_view(request, task=obj))
6162
elif scope == Scopes.CREATE:
63+
# Note: POST /api/quality/reports is used to initiate report creation and to check the process status
64+
rq_id = request.query_params.get("rq_id")
6265
task_id = request.data.get("task_id")
66+
67+
if not (task_id or rq_id):
68+
raise PermissionDenied("Either task_id or rq_id must be specified")
69+
70+
if rq_id:
71+
# There will be another check for this case during request processing
72+
continue
73+
6374
if task_id is not None:
64-
permissions.append(TaskPermission.create_scope_view(request, task_id))
75+
# The request may have a different org or org unset
76+
# Here we need to retrieve iam_context for this user, based on the task_id
77+
try:
78+
task = Task.objects.get(id=task_id)
79+
except Task.DoesNotExist:
80+
raise ValidationError("The specified task does not exist")
81+
82+
iam_context = get_iam_context(request, task)
83+
84+
permissions.append(
85+
TaskPermission.create_scope_view(request, task, iam_context=iam_context)
86+
)
87+
88+
permissions.append(
89+
cls.create_base_perm(
90+
request,
91+
view,
92+
scope,
93+
iam_context,
94+
obj,
95+
task_id=task_id,
96+
)
97+
)
6598

66-
permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj))
6799
else:
68100
permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj))
69101

@@ -91,15 +123,28 @@ def get_scopes(request, view, obj):
91123
def get_resource(self):
92124
data = None
93125

94-
if self.obj:
95-
task = self.obj.get_task()
96-
if task.project:
97-
organization = task.project.organization
126+
if self.obj or self.scope == self.Scopes.CREATE:
127+
task: Optional[Task] = None
128+
project: Optional[Project] = None
129+
obj_id: Optional[int] = None
130+
131+
if self.obj:
132+
obj_id = self.obj.id
133+
task = self.obj.get_task()
134+
elif self.scope == self.Scopes.CREATE and self.task_id:
135+
try:
136+
task = Task.objects.get(id=self.task_id)
137+
except Task.DoesNotExist:
138+
raise ValidationError("The specified task does not exist")
139+
140+
if task and task.project:
141+
project = task.project
142+
organization = project.organization
98143
else:
99-
organization = task.organization
144+
organization = getattr(task, "organization", None)
100145

101146
data = {
102-
"id": self.obj.id,
147+
"id": obj_id,
103148
"organization": {"id": getattr(organization, "id", None)},
104149
"task": (
105150
{
@@ -111,15 +156,15 @@ def get_resource(self):
111156
),
112157
"project": (
113158
{
114-
"owner": {"id": getattr(task.project.owner, "id", None)},
115-
"assignee": {"id": getattr(task.project.assignee, "id", None)},
159+
"owner": {"id": getattr(project.owner, "id", None)},
160+
"assignee": {"id": getattr(project.assignee, "id", None)},
116161
}
117-
if task.project
162+
if project
118163
else None
119164
),
120165
}
121166
elif self.scope == self.Scopes.VIEW_STATUS:
122-
data = {"owner": self.job_owner_id}
167+
data = {"owner": {"id": self.job_owner_id}}
123168

124169
return data
125170

Diff for: cvat/apps/quality_control/quality_reports.py

+55-21
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,21 @@
1111
from datetime import timedelta
1212
from functools import cached_property, partial
1313
from typing import Any, Callable, Dict, Hashable, List, Optional, Sequence, Tuple, Union, cast
14-
from uuid import uuid4
1514

1615
import datumaro as dm
1716
import datumaro.util.mask_tools
1817
import django_rq
1918
import numpy as np
19+
import rq
2020
from attrs import asdict, define, fields_dict
2121
from datumaro.util import dump_json, parse_json
2222
from django.conf import settings
2323
from django.db import transaction
2424
from django.utils import timezone
25+
from django_rq.queues import DjangoRQ as RqQueue
26+
from rest_framework.request import Request
27+
from rq.job import Job as RqJob
28+
from rq_scheduler import Scheduler as RqScheduler
2529
from scipy.optimize import linear_sum_assignment
2630

2731
from cvat.apps.dataset_manager.bindings import (
@@ -48,6 +52,7 @@
4852
User,
4953
ValidationMode,
5054
)
55+
from cvat.apps.engine.utils import define_dependent_job, get_rq_job_meta, get_rq_lock_by_user
5156
from cvat.apps.profiler import silk_profile
5257
from cvat.apps.quality_control import models
5358
from cvat.apps.quality_control.models import (
@@ -2115,25 +2120,30 @@ def generate_report(self) -> ComparisonReport:
21152120

21162121

21172122
class QualityReportUpdateManager:
2118-
_QUEUE_JOB_PREFIX = "update-quality-metrics-task-"
2123+
_QUEUE_AUTOUPDATE_JOB_PREFIX = "update-quality-metrics-"
2124+
_QUEUE_CUSTOM_JOB_PREFIX = "quality-check-"
21192125
_RQ_CUSTOM_QUALITY_CHECK_JOB_TYPE = "custom_quality_check"
21202126
_JOB_RESULT_TTL = 120
21212127

21222128
@classmethod
21232129
def _get_quality_check_job_delay(cls) -> timedelta:
21242130
return timedelta(seconds=settings.QUALITY_CHECK_JOB_DELAY)
21252131

2126-
def _get_scheduler(self):
2132+
def _get_scheduler(self) -> RqScheduler:
21272133
return django_rq.get_scheduler(settings.CVAT_QUEUES.QUALITY_REPORTS.value)
21282134

2129-
def _get_queue(self):
2135+
def _get_queue(self) -> RqQueue:
21302136
return django_rq.get_queue(settings.CVAT_QUEUES.QUALITY_REPORTS.value)
21312137

21322138
def _make_queue_job_id_base(self, task: Task) -> str:
2133-
return f"{self._QUEUE_JOB_PREFIX}{task.id}"
2139+
return f"{self._QUEUE_AUTOUPDATE_JOB_PREFIX}task-{task.id}"
21342140

2135-
def _make_custom_quality_check_job_id(self) -> str:
2136-
return uuid4().hex
2141+
def _make_custom_quality_check_job_id(self, task_id: int, user_id: int) -> str:
2142+
# FUTURE-TODO: it looks like job ID template should not include user_id because:
2143+
# 1. There is no need to compute quality reports several times for different users
2144+
# 2. Each user (not only rq job owner) that has permission to access a task should
2145+
# be able to check the status of the computation process
2146+
return f"{self._QUEUE_CUSTOM_JOB_PREFIX}task-{task_id}-user-{user_id}"
21372147

21382148
@classmethod
21392149
def _get_last_report_time(cls, task: Task) -> Optional[timezone.datetime]:
@@ -2186,28 +2196,52 @@ def schedule_quality_autoupdate_job(self, task: Task):
21862196
task_id=task.id,
21872197
)
21882198

2189-
def schedule_quality_check_job(self, task: Task, *, user_id: int) -> str:
2199+
class JobAlreadyExists(QualityReportsNotAvailable):
2200+
def __str__(self):
2201+
return "Quality computation job for this task already enqueued"
2202+
2203+
def schedule_custom_quality_check_job(
2204+
self, request: Request, task: Task, *, user_id: int
2205+
) -> str:
21902206
"""
21912207
Schedules a quality report computation job, supposed for updates by a request.
21922208
"""
21932209

21942210
self._check_quality_reporting_available(task)
21952211

2196-
rq_id = self._make_custom_quality_check_job_id()
2197-
21982212
queue = self._get_queue()
2199-
queue.enqueue(
2200-
self._check_task_quality,
2201-
task_id=task.id,
2202-
job_id=rq_id,
2203-
meta={"user_id": user_id, "job_type": self._RQ_CUSTOM_QUALITY_CHECK_JOB_TYPE},
2204-
result_ttl=self._JOB_RESULT_TTL,
2205-
failure_ttl=self._JOB_RESULT_TTL,
2206-
)
2213+
2214+
with get_rq_lock_by_user(queue, user_id=user_id):
2215+
rq_id = self._make_custom_quality_check_job_id(task_id=task.id, user_id=user_id)
2216+
rq_job = queue.fetch_job(rq_id)
2217+
if rq_job:
2218+
if rq_job.get_status(refresh=False) in (
2219+
rq.job.JobStatus.QUEUED,
2220+
rq.job.JobStatus.STARTED,
2221+
rq.job.JobStatus.SCHEDULED,
2222+
rq.job.JobStatus.DEFERRED,
2223+
):
2224+
raise self.JobAlreadyExists()
2225+
2226+
rq_job.delete()
2227+
2228+
dependency = define_dependent_job(
2229+
queue, user_id=user_id, rq_id=rq_id, should_be_dependent=True
2230+
)
2231+
2232+
queue.enqueue(
2233+
self._check_task_quality,
2234+
task_id=task.id,
2235+
job_id=rq_id,
2236+
meta=get_rq_job_meta(request=request, db_obj=task),
2237+
result_ttl=self._JOB_RESULT_TTL,
2238+
failure_ttl=self._JOB_RESULT_TTL,
2239+
depends_on=dependency,
2240+
)
22072241

22082242
return rq_id
22092243

2210-
def get_quality_check_job(self, rq_id: str):
2244+
def get_quality_check_job(self, rq_id: str) -> Optional[RqJob]:
22112245
queue = self._get_queue()
22122246
rq_job = queue.fetch_job(rq_id)
22132247

@@ -2216,8 +2250,8 @@ def get_quality_check_job(self, rq_id: str):
22162250

22172251
return rq_job
22182252

2219-
def is_custom_quality_check_job(self, rq_job) -> bool:
2220-
return rq_job.meta.get("job_type") == self._RQ_CUSTOM_QUALITY_CHECK_JOB_TYPE
2253+
def is_custom_quality_check_job(self, rq_job: RqJob) -> bool:
2254+
return isinstance(rq_job.id, str) and rq_job.id.startswith(self._QUEUE_CUSTOM_JOB_PREFIX)
22212255

22222256
@classmethod
22232257
@silk_profile()

Diff for: cvat/apps/quality_control/rules/quality_reports.rego

+32-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import rego.v1
44

55
import data.utils
66
import data.organizations
7+
import data.quality_utils
78

89
# input: {
9-
# "scope": <"view"|"list"|"create"|"view:status"> or null,
10+
# "scope": <"create"|"view"|"view:status"|"list"> or null,
1011
# "auth": {
1112
# "user": {
1213
# "id": <num>,
@@ -23,7 +24,7 @@ import data.organizations
2324
# } or null,
2425
# },
2526
# "resource": {
26-
# "id": <num>,
27+
# "id": <num> or null,
2728
# "owner": { "id": <num> },
2829
# "organization": { "id": <num> } or null,
2930
# "task": {
@@ -41,6 +42,8 @@ import data.organizations
4142
# }
4243
# }
4344

45+
46+
4447
default allow := false
4548

4649
allow if {
@@ -57,6 +60,33 @@ allow if {
5760
organizations.is_member
5861
}
5962

63+
allow if {
64+
input.scope == utils.VIEW_STATUS
65+
utils.is_resource_owner
66+
}
67+
68+
allow if {
69+
input.scope in {utils.CREATE, utils.VIEW}
70+
utils.is_sandbox
71+
quality_utils.is_task_staff(input.resource.task, input.resource.project, input.auth)
72+
utils.has_perm(utils.WORKER)
73+
}
74+
75+
allow if {
76+
input.scope in {utils.CREATE, utils.VIEW}
77+
input.auth.organization.id == input.resource.organization.id
78+
utils.has_perm(utils.USER)
79+
organizations.has_perm(organizations.MAINTAINER)
80+
}
81+
82+
allow if {
83+
input.scope in {utils.CREATE, utils.VIEW}
84+
quality_utils.is_task_staff(input.resource.task, input.resource.project, input.auth)
85+
input.auth.organization.id == input.resource.organization.id
86+
utils.has_perm(utils.WORKER)
87+
organizations.has_perm(organizations.WORKER)
88+
}
89+
6090
filter := [] if { # Django Q object to filter list of entries
6191
utils.is_admin
6292
utils.is_sandbox

Diff for: cvat/apps/quality_control/rules/quality_settings.rego

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import data.utils
66
import data.organizations
77

88
# input: {
9-
# "scope": <"view"> or null,
9+
# "scope": <"list"> or null,
1010
# "auth": {
1111
# "user": {
1212
# "id": <num>,

Diff for: cvat/apps/quality_control/rules/quality_utils.rego

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package quality_utils
2+
3+
import rego.v1
4+
5+
6+
is_task_owner(task_data, auth_data) if {
7+
task_data.owner.id == auth_data.user.id
8+
}
9+
10+
is_task_assignee(task_data, auth_data) if {
11+
task_data.assignee.id == auth_data.user.id
12+
}
13+
14+
is_project_owner(project_data, auth_data) if {
15+
project_data.owner.id == auth_data.user.id
16+
}
17+
18+
is_project_assignee(project_data, auth_data) if {
19+
project_data.assignee.id == auth_data.user.id
20+
}
21+
22+
is_project_staff(project_data, auth_data) if {
23+
is_project_owner(project_data, auth_data)
24+
}
25+
26+
is_project_staff(project_data, auth_data) if {
27+
is_project_assignee(project_data, auth_data)
28+
}
29+
30+
is_task_staff(task_data, project_data, auth_data) if {
31+
is_project_staff(project_data, auth_data)
32+
}
33+
34+
is_task_staff(task_data, project_data, auth_data) if {
35+
is_task_owner(task_data, auth_data)
36+
}
37+
38+
is_task_staff(task_data, project_data, auth_data) if {
39+
is_task_assignee(task_data, auth_data)
40+
}

0 commit comments

Comments
 (0)