Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show job assignee from quality reports in UI #8123

Merged
merged 18 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added

- The server will now record and report last assignee update time
(<https://github.com/cvat-ai/cvat/pull/8119>)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Changed

- Quality analytics page will now report job assignees from quality reports
instead of current job assignees
(<https://github.com/cvat-ai/cvat/pull/8123>)
12 changes: 12 additions & 0 deletions cvat-core/src/quality-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT

import { SerializedQualityReportData } from './server-response-types';
import User from './user';

export interface QualitySummary {
frameCount: number;
Expand Down Expand Up @@ -36,6 +37,7 @@ export default class QualityReport {
#target: string;
#createdDate: string;
#gtLastUpdated: string;
#assignee: User | null;
#summary: Partial<SerializedQualityReportData['summary']>;

constructor(initialData: SerializedQualityReportData) {
Expand All @@ -47,6 +49,12 @@ export default class QualityReport {
this.#gtLastUpdated = initialData.gt_last_updated;
this.#createdDate = initialData.created_date;
this.#summary = initialData.summary;

if (initialData.assignee) {
this.#assignee = new User(initialData.assignee);
} else {
this.#assignee = null;
}
}

get id(): number {
Expand Down Expand Up @@ -77,6 +85,10 @@ export default class QualityReport {
return this.#createdDate;
}

get assignee(): User | null {
return this.#assignee;
}

get summary(): QualitySummary {
return {
frameCount: this.#summary.frame_count,
Expand Down
1 change: 1 addition & 0 deletions cvat-core/src/server-response-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export interface SerializedQualityReportData {
target: string;
created_date?: string;
gt_last_updated?: string;
assignee?: SerializedUser | null;
summary?: {
frame_count: number;
frame_share: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,12 @@ function JobListComponent(props: Props): JSX.Element {
function collectUsers(path: string): ColumnFilterItem[] {
return Array.from<string | null>(
new Set(
jobs.map((job: any) => {
if (job[path] === null) {
Object.values(jobsReports).map((report: QualityReport) => {
if (report[path] === null) {
return null;
}

return job[path].username;
return report[path].username;
}),
),
).map((value: string | null) => ({ text: value || 'Is Empty', value: value || false }));
Expand Down Expand Up @@ -127,8 +127,8 @@ function JobListComponent(props: Props): JSX.Element {
dataIndex: 'assignee',
key: 'assignee',
className: 'cvat-job-item-assignee',
render: (jobInstance: any): JSX.Element => (
<Text>{jobInstance?.assignee?.username}</Text>
render: (report: QualityReport): JSX.Element => (
<Text>{report?.assignee?.username}</Text>
),
sorter: sorter('assignee.assignee.username'),
filters: collectUsers('assignee'),
Expand Down Expand Up @@ -232,7 +232,7 @@ function JobListComponent(props: Props): JSX.Element {
job: job.id,
download: job,
stage: job,
assignee: job,
assignee: report,
quality: report,
conflicts: report,
frame_intersection: report,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.13 on 2024-07-03 11:46

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("engine", "0078_alter_cloudstorage_credentials"),
]

operations = [
migrations.AddField(
model_name="job",
name="assignee_updated_date",
field=models.DateTimeField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name="project",
name="assignee_updated_date",
field=models.DateTimeField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name="task",
name="assignee_updated_date",
field=models.DateTimeField(blank=True, default=None, null=True),
),
]
10 changes: 7 additions & 3 deletions cvat/apps/engine/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Copyright (C) 2018-2022 Intel Corporation
# Copyright (C) 2022-2023 CVAT.ai Corporation
# Copyright (C) 2022-2024 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

Expand Down Expand Up @@ -331,6 +331,8 @@ class Project(TimestampedModel):
on_delete=models.SET_NULL, related_name="+")
assignee = models.ForeignKey(User, null=True, blank=True,
on_delete=models.SET_NULL, related_name="+")
assignee_updated_date = models.DateTimeField(null=True, blank=True, default=None)

bug_tracker = models.CharField(max_length=2000, blank=True, default="")
status = models.CharField(max_length=32, choices=StatusChoice.choices(),
default=StatusChoice.ANNOTATION)
Expand Down Expand Up @@ -402,6 +404,7 @@ class Task(TimestampedModel):
on_delete=models.SET_NULL, related_name="owners")
assignee = models.ForeignKey(User, null=True, blank=True,
on_delete=models.SET_NULL, related_name="assignees")
assignee_updated_date = models.DateTimeField(null=True, blank=True, default=None)
bug_tracker = models.CharField(max_length=2000, blank=True, default="")
overlap = models.PositiveIntegerField(null=True)
# Zero means that there are no limits (default)
Expand Down Expand Up @@ -662,7 +665,9 @@ class Job(TimestampedModel):
objects = JobQuerySet.as_manager()

segment = models.ForeignKey(Segment, on_delete=models.CASCADE)

assignee = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
assignee_updated_date = models.DateTimeField(null=True, blank=True, default=None)

# TODO: it has to be deleted in Job, Task, Project and replaced by (stage, state)
# The stage field cannot be changed by an assignee, but state field can be. For
Expand Down Expand Up @@ -706,8 +711,7 @@ def get_guide_id(self):

@extend_schema_field(OpenApiTypes.INT)
def get_task_id(self):
task = self.segment.task
return task.id if task else None
return self.segment.task_id

@property
def organization_id(self):
Expand Down
55 changes: 42 additions & 13 deletions cvat/apps/engine/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Copyright (C) 2019-2022 Intel Corporation
# Copyright (C) 2022-2023 CVAT.ai Corporation
# Copyright (C) 2022-2024 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

Expand All @@ -17,13 +17,14 @@
from typing import Any, Dict, Iterable, Optional, OrderedDict, Union

from rq.job import Job as RQJob, JobStatus as RQJobStatus
from datetime import timezone, timedelta
from datetime import timedelta
from decimal import Decimal

from rest_framework import serializers, exceptions
from django.contrib.auth.models import User, Group
from django.db import transaction
from django.db.models import TextChoices
from django.utils import timezone

from cvat.apps.dataset_manager.formats.utils import get_label_color
from cvat.apps.engine.utils import parse_exception_message
Expand Down Expand Up @@ -591,6 +592,7 @@ class JobReadSerializer(serializers.ModelSerializer):
stop_frame = serializers.ReadOnlyField(source="segment.stop_frame")
frame_count = serializers.ReadOnlyField(source="segment.frame_count")
assignee = BasicUserSerializer(allow_null=True, read_only=True)
assignee_updated_date = serializers.DateTimeField(allow_null=True, required=False)
dimension = serializers.CharField(max_length=2, source='segment.task.dimension', read_only=True)
data_chunk_size = serializers.ReadOnlyField(source='segment.task.data.chunk_size')
organization = serializers.ReadOnlyField(source='segment.task.organization.id', allow_null=True)
Expand All @@ -609,7 +611,7 @@ class Meta:
'dimension', 'bug_tracker', 'status', 'stage', 'state', 'mode', 'frame_count',
'start_frame', 'stop_frame', 'data_chunk_size', 'data_compressed_chunk_type',
'created_date', 'updated_date', 'issues', 'labels', 'type', 'organization',
'target_storage', 'source_storage')
'target_storage', 'source_storage', 'assignee_updated_date')
read_only_fields = fields

def to_representation(self, instance):
Expand Down Expand Up @@ -747,12 +749,17 @@ def create(self, validated_data):
raise serializers.ValidationError(f"Unexpected job type '{validated_data['type']}'")

validated_data['segment'] = segment
validated_data["assignee_id"] = validated_data.pop("assignee", None)

try:
job = super().create(validated_data)
except models.TaskGroundTruthJobsLimitError as ex:
raise serializers.ValidationError(ex.message) from ex

if validated_data.get("assignee_id"):
job.assignee_updated_date = job.updated_date
job.save(update_fields=["assignee_updated_date"])

job.make_dirs()
return job

Expand All @@ -771,12 +778,13 @@ def update(self, instance, validated_data):
if state != instance.state:
validated_data['state'] = state

assignee = validated_data.get('assignee')
if assignee is not None:
validated_data['assignee'] = User.objects.get(id=assignee)
if "assignee" in validated_data and (
(assignee_id := validated_data.pop("assignee")) != instance.assignee_id
):
validated_data["assignee_id"] = assignee_id
validated_data["assignee_updated_date"] = timezone.now()

instance = super().update(instance, validated_data)

return instance

class SimpleJobSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -1103,6 +1111,7 @@ class TaskReadSerializer(serializers.ModelSerializer):
data = serializers.ReadOnlyField(source='data.id', required=False)
owner = BasicUserSerializer(required=False, allow_null=True)
assignee = BasicUserSerializer(allow_null=True, required=False)
assignee_updated_date = serializers.DateTimeField(allow_null=True, required=False)
project_id = serializers.IntegerField(required=False, allow_null=True)
guide_id = serializers.IntegerField(source='annotation_guide.id', required=False, allow_null=True)
dimension = serializers.CharField(allow_blank=True, required=False)
Expand All @@ -1118,6 +1127,7 @@ class Meta:
'status', 'data_chunk_size', 'data_compressed_chunk_type', 'guide_id',
'data_original_chunk_type', 'size', 'image_quality', 'data', 'dimension',
'subset', 'organization', 'target_storage', 'source_storage', 'jobs', 'labels',
'assignee_updated_date'
)
read_only_fields = fields
extra_kwargs = {
Expand Down Expand Up @@ -1185,6 +1195,9 @@ def create(self, validated_data):

LabelSerializer.create_labels(labels, parent_instance=db_task)

if validated_data.get('assignee_id'):
db_task.assignee_updated_date = db_task.updated_date

db_task.save()
return db_task

Expand All @@ -1193,12 +1206,17 @@ def create(self, validated_data):
def update(self, instance, validated_data):
instance.name = validated_data.get('name', instance.name)
instance.owner_id = validated_data.get('owner_id', instance.owner_id)
instance.assignee_id = validated_data.get('assignee_id', instance.assignee_id)
instance.bug_tracker = validated_data.get('bug_tracker',
instance.bug_tracker)
instance.bug_tracker = validated_data.get('bug_tracker', instance.bug_tracker)
instance.subset = validated_data.get('subset', instance.subset)
labels = validated_data.get('label_set', [])

if (
"assignee_id" in validated_data and
validated_data["assignee_id"] != instance.assignee_id
):
instance.assignee_id = validated_data.pop('assignee_id')
instance.assignee_updated_date = timezone.now()

if instance.project_id is None:
LabelSerializer.update_labels(labels, parent_instance=instance)

Expand Down Expand Up @@ -1324,6 +1342,7 @@ def validate(self, attrs):
class ProjectReadSerializer(serializers.ModelSerializer):
owner = BasicUserSerializer(allow_null=True, required=False, read_only=True)
assignee = BasicUserSerializer(allow_null=True, required=False, read_only=True)
assignee_updated_date = serializers.DateTimeField(allow_null=True, required=False)
guide_id = serializers.IntegerField(source='annotation_guide.id', required=False, allow_null=True)
task_subsets = serializers.ListField(child=serializers.CharField(), required=False, read_only=True)
dimension = serializers.CharField(max_length=16, required=False, read_only=True, allow_null=True)
Expand All @@ -1337,7 +1356,7 @@ class Meta:
fields = ('url', 'id', 'name', 'owner', 'assignee', 'guide_id',
'bug_tracker', 'task_subsets', 'created_date', 'updated_date', 'status',
'dimension', 'organization', 'target_storage', 'source_storage',
'tasks', 'labels',
'tasks', 'labels', 'assignee_updated_date'
)
read_only_fields = fields
extra_kwargs = { 'organization': { 'allow_null': True } }
Expand Down Expand Up @@ -1391,17 +1410,27 @@ def create(self, validated_data):

LabelSerializer.create_labels(labels, parent_instance=db_project)

if validated_data.get("assignee_id"):
db_project.assignee_updated_date = db_project.updated_date
db_project.save(update_fields=["assignee_updated_date"])

return db_project

# pylint: disable=no-self-use
@transaction.atomic
def update(self, instance, validated_data):
instance.name = validated_data.get('name', instance.name)
instance.owner_id = validated_data.get('owner_id', instance.owner_id)
instance.assignee_id = validated_data.get('assignee_id', instance.assignee_id)
instance.bug_tracker = validated_data.get('bug_tracker', instance.bug_tracker)
labels = validated_data.get('label_set', [])

if (
"assignee_id" in validated_data and
validated_data['assignee_id'] != instance.assignee_id
):
instance.assignee_id = validated_data.pop('assignee_id')
instance.assignee_updated_date = timezone.now()

labels = validated_data.get('label_set', [])
LabelSerializer.update_labels(labels, parent_instance=instance)

# update source and target storages
Expand Down
1 change: 1 addition & 0 deletions cvat/apps/engine/tests/test_rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3010,6 +3010,7 @@ def _run_api_v2_tasks_id_export_import(self, user):
"owner",
"project_id",
"assignee",
"assignee_updated_date",
"created_date",
"updated_date",
"data",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.2.13 on 2024-07-04 16:28

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("quality_control", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="qualityreport",
name="assignee",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="quality_report",
to=settings.AUTH_USER_MODEL,
),
),
]
8 changes: 6 additions & 2 deletions cvat/apps/quality_control/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 CVAT.ai Corporation
# Copyright (C) 2023-2024 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

Expand All @@ -12,7 +12,7 @@
from django.db import models
from django.forms.models import model_to_dict

from cvat.apps.engine.models import Job, ShapeType, Task
from cvat.apps.engine.models import Job, ShapeType, Task, User


class AnnotationConflictType(str, Enum):
Expand Down Expand Up @@ -86,6 +86,10 @@ class QualityReport(models.Model):
target_last_updated = models.DateTimeField()
gt_last_updated = models.DateTimeField()

assignee = models.ForeignKey(
User, on_delete=models.SET_NULL, related_name="quality_report", null=True, blank=True
)

data = models.JSONField()

conflicts: Sequence[AnnotationConflict]
Expand Down
Loading
Loading