Skip to content

Commit

Permalink
Track last assignee update (#8119)
Browse files Browse the repository at this point in the history
<!-- Raise an issue to propose your change
(https://github.com/cvat-ai/cvat/issues).
It helps to avoid duplication of efforts from multiple independent
contributors.
Discuss your ideas with maintainers to be sure that changes will be
approved and merged.
Read the [Contribution guide](https://docs.cvat.ai/docs/contributing/).
-->

<!-- Provide a general summary of your changes in the Title above -->

Depends on #8162

### Motivation and context
<!-- Why is this change required? What problem does it solve? If it
fixes an open
issue, please link to the issue here. Describe your changes in detail,
add
screenshots. -->

- Added recording and reporting of the last assignee update time on the
server
- Added reporting of the assignee in quality reports

### How has this been tested?
<!-- Please describe in detail how you tested your changes.
Include details of your testing environment, and the tests you ran to
see how your change affects other areas of the code, etc. -->

Unit tests

### Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply.
If an item isn't applicable for some reason, then ~~explicitly
strikethrough~~ the whole
line. If you don't do that, GitHub will show incorrect progress for the
pull request.
If you're unsure about any of these, don't hesitate to ask. We're here
to help! -->
- [ ] I submit my changes into the `develop` branch
- [ ] I have created a changelog fragment <!-- see top comment in
CHANGELOG.md -->
- [ ] I have updated the documentation accordingly
- [ ] I have added tests to cover my changes
- [ ] I have linked related issues (see [GitHub docs](

https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword))
- [ ] I have increased versions of npm packages if it is necessary

([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning),

[cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning),

[cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning)
and

[cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning))

### License

- [ ] I submit _my code changes_ under the same [MIT License](
https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the
project.
  Feel free to contact the maintainers if that's a concern.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added tracking for the last assignee update time across jobs, tasks,
and projects.
  - Introduced assignee information in quality reports.
- **Bug Fixes**
- Ensured accurate handling of assignee details and update times in
various entities.
- **Tests**
- Added extensive test coverage for creating and updating assignee
details in jobs, tasks, projects, and quality reports.
- **Documentation**
  - Updated schema to include the new `assignee_updated_date` field.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
zhiltsov-max authored Jul 15, 2024
1 parent b2f6097 commit b5d48c7
Show file tree
Hide file tree
Showing 20 changed files with 530 additions and 28 deletions.
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,28 @@
# Generated by Django 4.2.13 on 2024-07-12 19:06

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("engine", "0080_alter_trackedshape_track"),
]

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
54 changes: 40 additions & 14 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 @@ -609,7 +610,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 +748,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 +777,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 @@ -1118,6 +1125,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,20 +1193,28 @@ def create(self, validated_data):

LabelSerializer.create_labels(labels, parent_instance=db_task)

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

return db_task

# 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)
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 @@ -1337,7 +1353,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 +1407,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-12 19:06

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_reports",
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_reports", null=True, blank=True
)

data = models.JSONField()

conflicts: Sequence[AnnotationConflict]
Expand Down
19 changes: 18 additions & 1 deletion cvat/apps/quality_control/quality_reports.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 Down Expand Up @@ -34,6 +34,7 @@
from cvat.apps.dataset_manager.formats.registry import dm_env
from cvat.apps.dataset_manager.task import JobAnnotation
from cvat.apps.dataset_manager.util import bulk_create
from cvat.apps.engine import serializers as engine_serializers
from cvat.apps.engine.models import (
DimensionType,
Job,
Expand All @@ -42,6 +43,7 @@
StageChoice,
StatusChoice,
Task,
User,
)
from cvat.apps.profiler import silk_profile
from cvat.apps.quality_control import models
Expand Down Expand Up @@ -2299,6 +2301,7 @@ def _compute_reports(self, task_id: int) -> int:
job=job,
target_last_updated=job.updated_date,
gt_last_updated=gt_job.updated_date,
assignee_id=job.assignee_id,
data=job_comparison_report.to_json(),
conflicts=[c.to_dict() for c in job_comparison_report.conflicts],
)
Expand All @@ -2310,6 +2313,7 @@ def _compute_reports(self, task_id: int) -> int:
task=task,
target_last_updated=task.updated_date,
gt_last_updated=gt_job.updated_date,
assignee_id=task.assignee_id,
data=task_comparison_report.to_json(),
conflicts=[], # the task doesn't have own conflicts
),
Expand Down Expand Up @@ -2415,6 +2419,7 @@ def _save_reports(self, *, task_report: Dict, job_reports: List[Dict]) -> models
task=task_report["task"],
target_last_updated=task_report["target_last_updated"],
gt_last_updated=task_report["gt_last_updated"],
assignee_id=task_report["assignee_id"],
data=task_report["data"],
)
db_task_report.save()
Expand All @@ -2426,6 +2431,7 @@ def _save_reports(self, *, task_report: Dict, job_reports: List[Dict]) -> models
job=job_report["job"],
target_last_updated=job_report["target_last_updated"],
gt_last_updated=job_report["gt_last_updated"],
assignee_id=job_report["assignee_id"],
data=job_report["data"],
)
db_job_reports.append(db_job_report)
Expand Down Expand Up @@ -2481,6 +2487,16 @@ def prepare_report_for_downloading(db_report: models.QualityReport, *, host: str
# - convert some fractions to percents
# - add common report info

def _serialize_assignee(assignee: Optional[User]) -> Optional[dict]:
if not db_report.assignee:
return None

reported_keys = ["id", "username", "first_name", "last_name"]
assert set(reported_keys).issubset(engine_serializers.BasicUserSerializer.Meta.fields)
# check that only safe fields are reported

return {k: getattr(assignee, k) for k in reported_keys}

task_id = db_report.get_task().id
serialized_data = dict(
job_id=db_report.job.id if db_report.job is not None else None,
Expand All @@ -2489,6 +2505,7 @@ def prepare_report_for_downloading(db_report: models.QualityReport, *, host: str
created_date=str(db_report.created_date),
target_last_updated=str(db_report.target_last_updated),
gt_last_updated=str(db_report.gt_last_updated),
assignee=_serialize_assignee(db_report.assignee),
)

comparison_report = ComparisonReport.from_json(db_report.get_json_report())
Expand Down
5 changes: 4 additions & 1 deletion cvat/apps/quality_control/serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Copyright (C) 2023 CVAT.ai Corporation
# Copyright (C) 2023-2024 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

import textwrap

from rest_framework import serializers

from cvat.apps.engine import serializers as engine_serializers
from cvat.apps.quality_control import models


Expand Down Expand Up @@ -43,6 +44,7 @@ class QualityReportSummarySerializer(serializers.Serializer):

class QualityReportSerializer(serializers.ModelSerializer):
target = serializers.ChoiceField(models.QualityReportTarget.choices())
assignee = engine_serializers.BasicUserSerializer(allow_null=True, read_only=True)
summary = QualityReportSummarySerializer()

class Meta:
Expand All @@ -57,6 +59,7 @@ class Meta:
"created_date",
"target_last_updated",
"gt_last_updated",
"assignee",
)
read_only_fields = fields

Expand Down
Loading

0 comments on commit b5d48c7

Please sign in to comment.