From 54c15830a9c52de54f636fc8e22eafb65ce9f0a9 Mon Sep 17 00:00:00 2001 From: Maria Khrustaleva Date: Tue, 28 Jun 2022 22:36:25 +0300 Subject: [PATCH] Add support for source & target storages (#28) --- cvat/apps/dataset_manager/project.py | 6 +- cvat/apps/dataset_manager/views.py | 28 +- cvat/apps/dataset_repo/dataset_repo.py | 6 +- cvat/apps/engine/backup.py | 119 +++- cvat/apps/engine/cloud_provider.py | 154 ++++- cvat/apps/engine/location.py | 36 ++ .../migrations/0054_auto_20220610_1829.py | 51 ++ .../migrations/0055_jobs_directories.py | 53 ++ cvat/apps/engine/mixins.py | 84 ++- cvat/apps/engine/models.py | 57 +- cvat/apps/engine/serializers.py | 235 ++++++-- cvat/apps/engine/signals.py | 2 +- cvat/apps/engine/task.py | 10 +- cvat/apps/engine/tests/test_rest_api.py | 16 +- cvat/apps/engine/views.py | 567 +++++++++++++----- cvat/apps/iam/permissions.py | 13 +- cvat/settings/base.py | 3 + cvat/settings/testing.py | 3 + .../rest_api/assets/cvat_db/cvat_data.tar.bz2 | Bin 75638 -> 46340 bytes tests/rest_api/assets/cvat_db/data.json | 73 ++- tests/rest_api/assets/jobs.json | 11 + tests/rest_api/assets/projects.json | 8 + tests/rest_api/assets/tasks.json | 18 + tests/rest_api/assets/users.json | 2 +- tests/rest_api/test_cloud_storages.py | 2 +- tests/rest_api/test_jobs.py | 11 +- 26 files changed, 1279 insertions(+), 289 deletions(-) create mode 100644 cvat/apps/engine/location.py create mode 100644 cvat/apps/engine/migrations/0054_auto_20220610_1829.py create mode 100644 cvat/apps/engine/migrations/0055_jobs_directories.py diff --git a/cvat/apps/dataset_manager/project.py b/cvat/apps/dataset_manager/project.py index a649ba22..c3c8d217 100644 --- a/cvat/apps/dataset_manager/project.py +++ b/cvat/apps/dataset_manager/project.py @@ -8,7 +8,7 @@ from typing import Any, Callable, List, Mapping, Tuple from django.db import transaction from cvat.apps.engine import models -from cvat.apps.engine.serializers import DataSerializer, TaskSerializer +from cvat.apps.engine.serializers import DataSerializer, TaskWriteSerializer from cvat.apps.engine.task import _create_thread as create_task from cvat.apps.dataset_manager.task import TaskAnnotation @@ -80,7 +80,7 @@ class ProjectAnnotationAndData: }) data_serializer.is_valid(raise_exception=True) db_data = data_serializer.save() - db_task = TaskSerializer.create(None, { + db_task = TaskWriteSerializer.create(None, { **task_fields, 'data_id': db_data.id, 'project_id': self.db_project.id @@ -161,4 +161,4 @@ def import_dataset_as_project(project_id, dataset_file, format_name): importer = make_importer(format_name) with open(dataset_file, 'rb') as f: - project.import_dataset(f, importer) \ No newline at end of file + project.import_dataset(f, importer) diff --git a/cvat/apps/dataset_manager/views.py b/cvat/apps/dataset_manager/views.py index a0127f3b..ec92c808 100644 --- a/cvat/apps/dataset_manager/views.py +++ b/cvat/apps/dataset_manager/views.py @@ -15,7 +15,7 @@ from django.utils import timezone import cvat.apps.dataset_manager.task as task import cvat.apps.dataset_manager.project as project from cvat.apps.engine.log import slogger -from cvat.apps.engine.models import Project, Task +from cvat.apps.engine.models import Project, Task, Job from .formats.registry import EXPORT_FORMATS, IMPORT_FORMATS from .util import current_function_name @@ -30,29 +30,36 @@ def log_exception(logger=None, exc_info=True): def get_export_cache_dir(db_instance): - base_dir = osp.abspath(db_instance.get_project_dirname() if isinstance(db_instance, Project) else db_instance.get_task_dirname()) + base_dir = osp.abspath(db_instance.get_dirname()) + if osp.isdir(base_dir): return osp.join(base_dir, 'export_cache') else: - raise Exception('{} dir {} does not exist'.format("Project" if isinstance(db_instance, Project) else "Task", base_dir)) + raise FileNotFoundError('{} dir {} does not exist'.format(db_instance.__class__.__name__, base_dir)) DEFAULT_CACHE_TTL = timedelta(hours=10) TASK_CACHE_TTL = DEFAULT_CACHE_TTL PROJECT_CACHE_TTL = DEFAULT_CACHE_TTL / 3 +JOB_CACHE_TTL = DEFAULT_CACHE_TTL -def export(dst_format, task_id=None, project_id=None, server_url=None, save_images=False): +def export(dst_format, project_id=None, task_id=None, job_id=None, server_url=None, save_images=False): try: if task_id is not None: db_instance = Task.objects.get(pk=task_id) logger = slogger.task[task_id] cache_ttl = TASK_CACHE_TTL export_fn = task.export_task - else: + elif project_id is not None: db_instance = Project.objects.get(pk=project_id) logger = slogger.project[project_id] cache_ttl = PROJECT_CACHE_TTL export_fn = project.export_project + else: + db_instance = Job.objects.get(pk=job_id) + logger = slogger.job[job_id] + cache_ttl = JOB_CACHE_TTL + export_fn = task.export_job cache_dir = get_export_cache_dir(db_instance) @@ -86,8 +93,9 @@ def export(dst_format, task_id=None, project_id=None, server_url=None, save_imag "The {} '{}' is exported as '{}' at '{}' " "and available for downloading for the next {}. " "Export cache cleaning job is enqueued, id '{}'".format( - "project" if isinstance(db_instance, Project) else 'task', - db_instance.name, dst_format, output_path, cache_ttl, + db_instance.__class__.__name__.lower(), + db_instance.name if isinstance(db_instance, (Project, Task)) else db_instance.id, + dst_format, output_path, cache_ttl, cleaning_job.id )) @@ -96,6 +104,12 @@ def export(dst_format, task_id=None, project_id=None, server_url=None, save_imag log_exception(logger) raise +def export_job_annotations(job_id, dst_format=None, server_url=None): + return export(dst_format,job_id=job_id, server_url=server_url, save_images=False) + +def export_job_as_dataset(job_id, dst_format=None, server_url=None): + return export(dst_format, job_id=job_id, server_url=server_url, save_images=True) + def export_task_as_dataset(task_id, dst_format=None, server_url=None): return export(dst_format, task_id=task_id, server_url=server_url, save_images=True) diff --git a/cvat/apps/dataset_repo/dataset_repo.py b/cvat/apps/dataset_repo/dataset_repo.py index 2786532f..7b1d5b56 100644 --- a/cvat/apps/dataset_repo/dataset_repo.py +++ b/cvat/apps/dataset_repo/dataset_repo.py @@ -26,7 +26,7 @@ from cvat.apps.engine.plugins import add_plugin def _have_no_access_exception(ex): if 'Permission denied' in ex.stderr or 'Could not read from remote repository' in ex.stderr: - keys = subprocess.run(['ssh-add -L'], shell = True, + keys = subprocess.run(['ssh-add', '-L'], #nosec stdout = subprocess.PIPE).stdout.decode('utf-8').split('\n') keys = list(filter(len, list(map(lambda x: x.strip(), keys)))) raise Exception( @@ -268,7 +268,7 @@ class Git: # Dump an annotation timestamp = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S") - dump_name = os.path.join(db_task.get_task_dirname(), + dump_name = os.path.join(db_task.get_dirname(), "git_annotation_{}_{}.zip".format(self._format, timestamp)) export_task( @@ -303,7 +303,7 @@ class Git: } old_diffs_dir = os.path.join(os.path.dirname(self._diffs_dir), 'repos_diffs') - if (os.path.isdir(old_diffs_dir)): + if os.path.isdir(old_diffs_dir): _read_old_diffs(old_diffs_dir, summary_diff) for diff_name in list(map(lambda x: os.path.join(self._diffs_dir, x), os.listdir(self._diffs_dir))): diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 739d70f9..58a01184 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -17,27 +17,35 @@ import django_rq from django.conf import settings from django.db import transaction from django.utils import timezone +from django.shortcuts import get_object_or_404 from rest_framework import serializers, status from rest_framework.parsers import JSONParser from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from django_sendfile import sendfile +from distutils.util import strtobool import cvat.apps.dataset_manager as dm from cvat.apps.engine import models from cvat.apps.engine.log import slogger from cvat.apps.engine.serializers import (AttributeSerializer, DataSerializer, - LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskSerializer, - ProjectSerializer, ProjectFileSerializer, TaskFileSerializer) + LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskReadSerializer, + ProjectReadSerializer, ProjectFileSerializer, TaskFileSerializer) from cvat.apps.engine.utils import av_scan_paths -from cvat.apps.engine.models import StorageChoice, StorageMethodChoice, DataChoice, Task, Project +from cvat.apps.engine.models import ( + StorageChoice, StorageMethodChoice, DataChoice, Task, Project, Location, + CloudStorage as CloudStorageModel) from cvat.apps.engine.task import _create_thread from cvat.apps.dataset_manager.views import TASK_CACHE_TTL, PROJECT_CACHE_TTL, get_export_cache_dir, clear_export_cache, log_exception from cvat.apps.dataset_manager.bindings import CvatImportError +from cvat.apps.engine.cloud_provider import ( + db_storage_to_storage_instance, validate_bucket_status +) +from cvat.apps.engine.location import StorageType, get_location_configuration class Version(Enum): - V1 = '1.0' + V1 = '1.0' def _get_label_mapping(db_labels): @@ -266,7 +274,7 @@ class TaskExporter(_ExporterBase, _TaskBackupBase): raise NotImplementedError() def _write_task(self, zip_object, target_dir=None): - task_dir = self._db_task.get_task_dirname() + task_dir = self._db_task.get_dirname() target_task_dir = os.path.join(target_dir, self.TASK_DIRNAME) if target_dir else self.TASK_DIRNAME self._write_directory( source_dir=task_dir, @@ -277,7 +285,7 @@ class TaskExporter(_ExporterBase, _TaskBackupBase): def _write_manifest(self, zip_object, target_dir=None): def serialize_task(): - task_serializer = TaskSerializer(self._db_task) + task_serializer = TaskReadSerializer(self._db_task) for field in ('url', 'owner', 'assignee', 'segments'): task_serializer.fields.pop(field) @@ -348,8 +356,8 @@ class TaskExporter(_ExporterBase, _TaskBackupBase): def export_to(self, file, target_dir=None): if self._db_task.data.storage_method == StorageMethodChoice.FILE_SYSTEM and \ - self._db_task.data.storage == StorageChoice.SHARE: - raise Exception('The task cannot be exported because it does not contain any raw data') + self._db_task.data.storage == StorageChoice.SHARE: + raise Exception('The task cannot be exported because it does not contain any raw data') if isinstance(file, str): with ZipFile(file, 'w') as zf: @@ -484,7 +492,7 @@ class TaskImporter(_ImporterBase, _TaskBackupBase): self._manifest['project_id'] = self._project_id self._db_task = models.Task.objects.create(**self._manifest, organization_id=self._org_id) - task_path = self._db_task.get_task_dirname() + task_path = self._db_task.get_dirname() if os.path.isdir(task_path): shutil.rmtree(task_path) @@ -569,7 +577,7 @@ class ProjectExporter(_ExporterBase, _ProjectBackupBase): def _write_manifest(self, zip_object): def serialize_project(): - project_serializer = ProjectSerializer(self._db_project) + project_serializer = ProjectReadSerializer(self._db_project) for field in ('assignee', 'owner', 'tasks', 'url'): project_serializer.fields.pop(field) @@ -591,7 +599,7 @@ class ProjectExporter(_ExporterBase, _ProjectBackupBase): self._write_manifest(output_file) class ProjectImporter(_ImporterBase, _ProjectBackupBase): - TASKNAME_RE = 'task_(\d+)/' + TASKNAME_RE = r'task_(\d+)/' def __init__(self, filename, user_id, org_id=None): super().__init__(logger=slogger.glob) @@ -616,7 +624,7 @@ class ProjectImporter(_ImporterBase, _ProjectBackupBase): self._manifest["owner_id"] = self._user_id self._db_project = models.Project.objects.create(**self._manifest, organization_id=self._org_id) - project_path = self._db_project.get_project_dirname() + project_path = self._db_project.get_dirname() if os.path.isdir(project_path): shutil.rmtree(project_path) os.makedirs(self._db_project.get_project_logs_dirname()) @@ -702,14 +710,23 @@ def export(db_instance, request): logger = slogger.task[db_instance.pk] Exporter = TaskExporter cache_ttl = TASK_CACHE_TTL + use_target_storage_conf = request.query_params.get('use_default_location', True) elif isinstance(db_instance, Project): filename_prefix = 'project' logger = slogger.project[db_instance.pk] Exporter = ProjectExporter cache_ttl = PROJECT_CACHE_TTL + use_target_storage_conf = request.query_params.get('use_default_location', True) else: raise Exception( "Unexpected type of db_isntance: {}".format(type(db_instance))) + use_settings = strtobool(str(use_target_storage_conf)) + obj = db_instance if use_settings else request.query_params + location_conf = get_location_configuration( + obj=obj, + use_settings=use_settings, + field_name=StorageType.TARGET + ) queue = django_rq.get_queue("default") rq_id = "/api/{}s/{}/backup".format(filename_prefix, db_instance.pk) @@ -731,8 +748,30 @@ def export(db_instance, request): filename = "{}_{}_backup_{}{}".format( filename_prefix, db_instance.name, timestamp, os.path.splitext(file_path)[1]) - return sendfile(request, file_path, attachment=True, - attachment_filename=filename.lower()) + + location = location_conf.get('location') + if location == Location.LOCAL: + return sendfile(request, file_path, attachment=True, + attachment_filename=filename.lower()) + elif location == Location.CLOUD_STORAGE: + + @validate_bucket_status + def _export_to_cloud_storage(storage, file_path, file_name): + storage.upload_file(file_path, file_name) + + try: + storage_id = location_conf['storage_id'] + except KeyError: + raise serializers.ValidationError( + 'Cloud storage location was selected for destination' + ' but cloud storage id was not specified') + db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) + storage = db_storage_to_storage_instance(db_storage) + + _export_to_cloud_storage(storage, file_path, filename) + return Response(status=status.HTTP_200_OK) + else: + raise NotImplementedError() else: if os.path.exists(file_path): return Response(status=status.HTTP_201_CREATED) @@ -753,21 +792,47 @@ def export(db_instance, request): result_ttl=ttl, failure_ttl=ttl) return Response(status=status.HTTP_202_ACCEPTED) -def _import(importer, request, rq_id, Serializer, file_field_name, filename=None): +def _import(importer, request, rq_id, Serializer, file_field_name, location_conf, filename=None): queue = django_rq.get_queue("default") rq_job = queue.fetch_job(rq_id) if not rq_job: org_id = getattr(request.iam_context['organization'], 'id', None) fd = None - if not filename: - serializer = Serializer(data=request.data) - serializer.is_valid(raise_exception=True) - payload_file = serializer.validated_data[file_field_name] + + location = location_conf.get('location') + if location == Location.LOCAL: + if not filename: + serializer = Serializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload_file = serializer.validated_data[file_field_name] + fd, filename = mkstemp(prefix='cvat_') + with open(filename, 'wb+') as f: + for chunk in payload_file.chunks(): + f.write(chunk) + else: + @validate_bucket_status + def _import_from_cloud_storage(storage, file_name): + return storage.download_fileobj(file_name) + + file_name = request.query_params.get('filename') + assert file_name + + # download file from cloud storage + try: + storage_id = location_conf['storage_id'] + except KeyError: + raise serializers.ValidationError( + 'Cloud storage location was selected for destination' + ' but cloud storage id was not specified') + db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) + storage = db_storage_to_storage_instance(db_storage) + + data = _import_from_cloud_storage(storage, file_name) + fd, filename = mkstemp(prefix='cvat_') with open(filename, 'wb+') as f: - for chunk in payload_file.chunks(): - f.write(chunk) + f.write(data.getbuffer()) rq_job = queue.enqueue_call( func=importer, args=(filename, request.user.id, org_id), @@ -814,12 +879,18 @@ def import_project(request, filename=None): Serializer = ProjectFileSerializer file_field_name = 'project_file' + location_conf = get_location_configuration( + obj=request.query_params, + field_name=StorageType.SOURCE, + ) + return _import( importer=_import_project, request=request, rq_id=rq_id, Serializer=Serializer, file_field_name=file_field_name, + location_conf=location_conf, filename=filename ) @@ -831,11 +902,17 @@ def import_task(request, filename=None): Serializer = TaskFileSerializer file_field_name = 'task_file' + location_conf = get_location_configuration( + obj=request.query_params, + field_name=StorageType.SOURCE + ) + return _import( importer=_import_task, request=request, rq_id=rq_id, Serializer=Serializer, file_field_name=file_field_name, + location_conf=location_conf, filename=filename ) diff --git a/cvat/apps/engine/cloud_provider.py b/cvat/apps/engine/cloud_provider.py index 4e2e1b26..0861ce92 100644 --- a/cvat/apps/engine/cloud_provider.py +++ b/cvat/apps/engine/cloud_provider.py @@ -4,10 +4,13 @@ import os import boto3 +import functools +import json from abc import ABC, abstractmethod, abstractproperty from enum import Enum from io import BytesIO +from rest_framework import serializers from boto3.s3.transfer import TransferConfig from botocore.exceptions import ClientError @@ -35,6 +38,14 @@ class Status(str, Enum): def __str__(self): return self.value +class Permissions(str, Enum): + READ = 'read' + WRITE = 'write' + + @classmethod + def all(cls): + return {i.value for i in cls} + class _CloudStorage(ABC): def __init__(self): @@ -86,7 +97,11 @@ class _CloudStorage(ABC): raise NotImplementedError("Unsupported type {} was found".format(type(file_obj))) @abstractmethod - def upload_file(self, file_obj, file_name): + def upload_fileobj(self, file_obj, file_name): + pass + + @abstractmethod + def upload_file(self, file_path, file_name=None): pass def __contains__(self, file_name): @@ -99,6 +114,18 @@ class _CloudStorage(ABC): def content(self): return list(map(lambda x: x['name'] , self._files)) + @abstractproperty + def supported_actions(self): + pass + + @property + def read_access(self): + return Permissions.READ in self.access + + @property + def write_access(self): + return Permissions.WRITE in self.access + def get_cloud_storage_instance(cloud_provider, resource, credentials, specific_attributes=None, endpoint=None): instance = None if cloud_provider == CloudProviderChoice.AWS_S3: @@ -133,6 +160,12 @@ class AWS_S3(_CloudStorage): transfer_config = { 'max_io_queue': 10, } + + class Effect(str, Enum): + ALLOW = 'Allow' + DENY = 'Deny' + + def __init__(self, bucket, region, @@ -209,13 +242,27 @@ class AWS_S3(_CloudStorage): def get_file_last_modified(self, key): return self._head_file(key).get('LastModified') - def upload_file(self, file_obj, file_name): + def upload_fileobj(self, file_obj, file_name): self._bucket.upload_fileobj( Fileobj=file_obj, Key=file_name, Config=TransferConfig(max_io_queue=self.transfer_config['max_io_queue']) ) + def upload_file(self, file_path, file_name=None): + if not file_name: + file_name = os.path.basename(file_path) + try: + self._bucket.upload_file( + file_path, + file_name, + Config=TransferConfig(max_io_queue=self.transfer_config['max_io_queue']) + ) + except ClientError as ex: + msg = str(ex) + slogger.glob.error(msg) + raise Exception(msg) + def initialize_content(self): files = self._bucket.objects.all() self._files = [{ @@ -251,8 +298,45 @@ class AWS_S3(_CloudStorage): slogger.glob.info(msg) raise Exception(msg) + def delete_file(self, file_name: str): + try: + self._client_s3.delete_object(Bucket=self.name, Key=file_name) + except Exception as ex: + msg = str(ex) + slogger.glob.info(msg) + raise + + @property + def supported_actions(self): + allowed_actions = set() + try: + bucket_policy = self._bucket.Policy().policy + except ClientError as ex: + if 'NoSuchBucketPolicy' in str(ex): + return Permissions.all() + else: + raise Exception(str(ex)) + bucket_policy = json.loads(bucket_policy) if isinstance(bucket_policy, str) else bucket_policy + for statement in bucket_policy['Statement']: + effect = statement.get('Effect') # Allow | Deny + actions = statement.get('Action', set()) + if effect == self.Effect.ALLOW: + allowed_actions.update(actions) + access = { + 's3:GetObject': Permissions.READ, + 's3:PutObject': Permissions.WRITE, + } + allowed_actions = Permissions.all() & {access.get(i) for i in allowed_actions} + + return allowed_actions + class AzureBlobContainer(_CloudStorage): MAX_CONCURRENCY = 3 + + + class Effect: + pass + def __init__(self, container, account_name, sas_token=None): super().__init__() self._account_name = account_name @@ -317,9 +401,18 @@ class AzureBlobContainer(_CloudStorage): else: return Status.NOT_FOUND - def upload_file(self, file_obj, file_name): + def upload_fileobj(self, file_obj, file_name): self._container_client.upload_blob(name=file_name, data=file_obj) + def upload_file(self, file_path, file_name=None): + if not file_name: + file_name = os.path.basename(file_path) + try: + with open(file_path, 'r') as f: + self.upload_fileobj(f, file_name) + except Exception as ex: + slogger.glob.error(str(ex)) + raise # TODO: # def multipart_upload(self, file_obj): @@ -342,6 +435,10 @@ class AzureBlobContainer(_CloudStorage): buf.seek(0) return buf + @property + def supported_actions(self): + pass + class GOOGLE_DRIVE(_CloudStorage): pass @@ -361,6 +458,9 @@ def _define_gcs_status(func): class GoogleCloudStorage(_CloudStorage): + class Effect: + pass + def __init__(self, bucket_name, prefix=None, service_account_json=None, anonymous_access=False, project=None, location=None): super().__init__() if service_account_json: @@ -416,9 +516,18 @@ class GoogleCloudStorage(_CloudStorage): buf.seek(0) return buf - def upload_file(self, file_obj, file_name): + def upload_fileobj(self, file_obj, file_name): self.bucket.blob(file_name).upload_from_file(file_obj) + def upload_file(self, file_path, file_name=None): + if not file_name: + file_name = os.path.basename(file_path) + try: + self.bucket.blob(file_name).upload_from_filename(file_path) + except Exception as ex: + slogger.glob.info(str(ex)) + raise + def create(self): try: self._bucket = self._storage_client.create_bucket( @@ -441,6 +550,10 @@ class GoogleCloudStorage(_CloudStorage): blob.reload() return blob.updated + @property + def supported_actions(self): + pass + class Credentials: __slots__ = ('key', 'secret_key', 'session_token', 'account_name', 'key_file_path', 'credentials_type') @@ -502,3 +615,36 @@ class Credentials: def values(self): return [self.key, self.secret_key, self.session_token, self.account_name, self.key_file_path] + + +def validate_bucket_status(func): + @functools.wraps(func) + def wrapper(storage, *args, **kwargs): + try: + res = func(storage, *args, **kwargs) + except Exception as ex: + # check that cloud storage exists + storage_status = storage.get_status() if storage is not None else None + if storage_status == Status.FORBIDDEN: + msg = 'The resource {} is no longer available. Access forbidden.'.format(storage.name) + elif storage_status == Status.NOT_FOUND: + msg = 'The resource {} not found. It may have been deleted.'.format(storage.name) + else: + msg = str(ex) + raise serializers.ValidationError(msg) + return res + return wrapper + + +def db_storage_to_storage_instance(db_storage): + credentials = Credentials() + credentials.convert_from_db({ + 'type': db_storage.credentials_type, + 'value': db_storage.credentials, + }) + details = { + 'resource': db_storage.resource, + 'credentials': credentials, + 'specific_attributes': db_storage.get_specific_attributes() + } + return get_cloud_storage_instance(cloud_provider=db_storage.provider_type, **details) diff --git a/cvat/apps/engine/location.py b/cvat/apps/engine/location.py new file mode 100644 index 00000000..fd3fadf0 --- /dev/null +++ b/cvat/apps/engine/location.py @@ -0,0 +1,36 @@ +from enum import Enum + +from cvat.apps.engine.models import Location + +class StorageType(str, Enum): + TARGET = 'target_storage' + SOURCE = 'source_storage' + + def __str__(self): + return self.value + +def get_location_configuration(obj, field_name, use_settings=False): + location_conf = dict() + if use_settings: + storage = getattr(obj, field_name) + if storage is None: + location_conf['location'] = Location.LOCAL + else: + location_conf['location'] = storage.location + sid = storage.cloud_storage_id + if sid: + location_conf['storage_id'] = sid + else: + # obj is query_params + # FIXME when ui part will be done + location_conf['location'] = obj.get('location', Location.LOCAL) + # try: + # location_conf['location'] = obj['location'] + # except KeyError: + # raise ValidationError("Custom settings were selected but no location was specified") + + sid = obj.get('cloud_storage_id') + if sid: + location_conf['storage_id'] = int(sid) + + return location_conf diff --git a/cvat/apps/engine/migrations/0054_auto_20220610_1829.py b/cvat/apps/engine/migrations/0054_auto_20220610_1829.py new file mode 100644 index 00000000..1c7ae1a8 --- /dev/null +++ b/cvat/apps/engine/migrations/0054_auto_20220610_1829.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.12 on 2022-06-10 18:29 + +import cvat.apps.engine.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0053_data_deleted_frames'), + ] + + operations = [ + migrations.CreateModel( + name='Storage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('location', models.CharField(choices=[('cloud_storage', 'CLOUD_STORAGE'), ('local', 'LOCAL')], default=cvat.apps.engine.models.Location['LOCAL'], max_length=16)), + ('cloud_storage_id', models.IntegerField(blank=True, default=None, null=True)), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.AddField( + model_name='job', + name='updated_date', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='project', + name='source_storage', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='engine.storage'), + ), + migrations.AddField( + model_name='project', + name='target_storage', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='engine.storage'), + ), + migrations.AddField( + model_name='task', + name='source_storage', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='engine.storage'), + ), + migrations.AddField( + model_name='task', + name='target_storage', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='engine.storage'), + ), + ] diff --git a/cvat/apps/engine/migrations/0055_jobs_directories.py b/cvat/apps/engine/migrations/0055_jobs_directories.py new file mode 100644 index 00000000..ec97f2c8 --- /dev/null +++ b/cvat/apps/engine/migrations/0055_jobs_directories.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.12 on 2022-06-10 18:29 + +import os +import shutil + +from django.db import migrations +from django.conf import settings +from cvat.apps.engine.log import get_logger + +MIGRATION_NAME = os.path.splitext(os.path.basename(__file__))[0] +MIGRATION_LOG = os.path.join(settings.MIGRATIONS_LOGS_ROOT, f"{MIGRATION_NAME}.log") + +def _get_query_set(apps): + Job = apps.get_model("engine", "Job") + query_set = Job.objects.all() + return query_set + +def _get_job_dir_path(jid): + return os.path.join(settings.JOBS_ROOT, str(jid)) + +def create_directories(apps, schema_editor): + logger = get_logger(MIGRATION_NAME, MIGRATION_LOG) + query_set = _get_query_set(apps) + logger.info(f'Migration has been started. Need to create {query_set.count()} directories.') + + for db_job in query_set: + jid = db_job.id + os.makedirs(_get_job_dir_path(jid), exist_ok=True) + logger.info(f'Migration has been finished successfully.') + +def delete_directories(apps, schema_editor): + logger = get_logger(MIGRATION_NAME, MIGRATION_LOG) + query_set = _get_query_set(apps) + logger.info(f'Reverse migration has been started. Need to delete {query_set.count()} directories.') + for db_job in query_set: + jid = db_job.id + job_dir = _get_job_dir_path(jid) + if os.path.isdir(job_dir): + shutil.rmtree(job_dir) + logger.info(f'Migration has been reversed successfully.') + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0054_auto_20220610_1829'), + ] + + operations = [ + migrations.RunPython( + code=create_directories, + reverse_code=delete_directories + ) + ] diff --git a/cvat/apps/engine/mixins.py b/cvat/apps/engine/mixins.py index 295e4ece..42f164ff 100644 --- a/cvat/apps/engine/mixins.py +++ b/cvat/apps/engine/mixins.py @@ -8,10 +8,13 @@ import uuid from django.conf import settings from django.core.cache import cache +from distutils.util import strtobool from rest_framework import status from rest_framework.response import Response -from cvat.apps.engine.serializers import DataSerializer +from cvat.apps.engine.models import Location +from cvat.apps.engine.location import StorageType, get_location_configuration +from cvat.apps.engine.serializers import DataSerializer, LabeledDataSerializer class TusFile: _tus_cache_timeout = 3600 @@ -90,7 +93,7 @@ class TusChunk: # This upload mixin is implemented using tus # tus is open protocol for file uploads (see more https://tus.io/) -class UploadMixin(object): +class UploadMixin: _tus_api_version = '1.0.0' _tus_api_version_supported = ['1.0.0'] _tus_api_extensions = [] @@ -238,3 +241,80 @@ class UploadMixin(object): # override this to do stuff after upload def upload_finished(self, request): raise NotImplementedError('You need to implement upload_finished in UploadMixin') + +class AnnotationMixin: + def export_annotations(self, request, pk, db_obj, export_func, callback, get_data=None): + format_name = request.query_params.get("format") + action = request.query_params.get("action", "").lower() + filename = request.query_params.get("filename", "") + + use_default_location = request.query_params.get("use_default_location", True) + use_settings = strtobool(str(use_default_location)) + obj = db_obj if use_settings else request.query_params + location_conf = get_location_configuration( + obj=obj, + use_settings=use_settings, + field_name=StorageType.TARGET, + ) + + rq_id = "/api/{}/{}/annotations/{}".format(self._object.__class__.__name__.lower(), pk, format_name) + + if format_name: + return export_func(db_instance=self._object, + rq_id=rq_id, + request=request, + action=action, + callback=callback, + format_name=format_name, + filename=filename, + location_conf=location_conf, + ) + + if not get_data: + return Response("Format is not specified",status=status.HTTP_400_BAD_REQUEST) + + data = get_data(pk) + serializer = LabeledDataSerializer(data=data) + if serializer.is_valid(raise_exception=True): + return Response(serializer.data) + + def import_annotations(self, request, pk, db_obj, import_func, rq_func): + use_default_location = request.query_params.get('use_default_location', True) + use_settings = strtobool(str(use_default_location)) + obj = db_obj if use_settings else request.query_params + location_conf = get_location_configuration( + obj=obj, + use_settings=use_settings, + field_name=StorageType.SOURCE, + ) + + if location_conf['location'] == Location.CLOUD_STORAGE: + format_name = request.query_params.get('format') + file_name = request.query_params.get('filename') + rq_id = "{}@/api/{}/{}/annotations/upload".format( + self._object.__class__.__name__.lower(), request.user, pk + ) + + return import_func( + request=request, + rq_id=rq_id, + rq_func=rq_func, + pk=pk, + format_name=format_name, + location_conf=location_conf, + filename=file_name, + ) + + return self.upload_data(request) + +class SerializeMixin: + def serialize(self, request, export_func): + db_object = self.get_object() # force to call check_object_permissions + return export_func(db_object, request) + + def deserialize(self, request, import_func): + location = request.query_params.get("location", Location.LOCAL) + if location == Location.CLOUD_STORAGE: + file_name = request.query_params.get("filename", "") + return import_func(request, filename=file_name) + return self.upload_data(request) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 5af453f1..af84195c 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -181,7 +181,7 @@ class Data(models.Model): default_permissions = () def get_frame_step(self): - match = re.search("step\s*=\s*([1-9]\d*)", self.frame_filter) + match = re.search(r"step\s*=\s*([1-9]\d*)", self.frame_filter) return int(match.group(1)) if match else 1 def get_data_dirname(self): @@ -265,7 +265,6 @@ class Image(models.Model): default_permissions = () class Project(models.Model): - name = SafeCharField(max_length=256) owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="+") @@ -278,15 +277,19 @@ class Project(models.Model): default=StatusChoice.ANNOTATION) organization = models.ForeignKey(Organization, null=True, default=None, blank=True, on_delete=models.SET_NULL, related_name="projects") + source_storage = models.ForeignKey('Storage', null=True, default=None, + blank=True, on_delete=models.SET_NULL, related_name='+') + target_storage = models.ForeignKey('Storage', null=True, default=None, + blank=True, on_delete=models.SET_NULL, related_name='+') - def get_project_dirname(self): + def get_dirname(self): return os.path.join(settings.PROJECTS_ROOT, str(self.id)) def get_project_logs_dirname(self): - return os.path.join(self.get_project_dirname(), 'logs') + return os.path.join(self.get_dirname(), 'logs') def get_tmp_dirname(self): - return os.path.join(self.get_project_dirname(), "tmp") + return os.path.join(self.get_dirname(), "tmp") def get_client_log_path(self): return os.path.join(self.get_project_logs_dirname(), "client.log") @@ -324,17 +327,20 @@ class Task(models.Model): subset = models.CharField(max_length=64, blank=True, default="") organization = models.ForeignKey(Organization, null=True, default=None, blank=True, on_delete=models.SET_NULL, related_name="tasks") - + source_storage = models.ForeignKey('Storage', null=True, default=None, + blank=True, on_delete=models.SET_NULL, related_name='+') + target_storage = models.ForeignKey('Storage', null=True, default=None, + blank=True, on_delete=models.SET_NULL, related_name='+') # Extend default permission model class Meta: default_permissions = () - def get_task_dirname(self): + def get_dirname(self): return os.path.join(settings.TASKS_ROOT, str(self.id)) def get_task_logs_dirname(self): - return os.path.join(self.get_task_dirname(), 'logs') + return os.path.join(self.get_dirname(), 'logs') def get_client_log_path(self): return os.path.join(self.get_task_logs_dirname(), "client.log") @@ -343,10 +349,10 @@ class Task(models.Model): return os.path.join(self.get_task_logs_dirname(), "task.log") def get_task_artifacts_dirname(self): - return os.path.join(self.get_task_dirname(), 'artifacts') + return os.path.join(self.get_dirname(), 'artifacts') def get_tmp_dirname(self): - return os.path.join(self.get_task_dirname(), "tmp") + return os.path.join(self.get_dirname(), "tmp") def __str__(self): return self.name @@ -414,6 +420,7 @@ class Segment(models.Model): class Job(models.Model): segment = models.ForeignKey(Segment, on_delete=models.CASCADE) assignee = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + updated_date = models.DateTimeField(auto_now=True) # 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 # now status is read only and it will be updated by (stage, state). Thus we don't @@ -425,6 +432,9 @@ class Job(models.Model): state = models.CharField(max_length=32, choices=StateChoice.choices(), default=StateChoice.NEW) + def get_dirname(self): + return os.path.join(settings.JOBS_ROOT, str(self.id)) + def get_project_id(self): project = self.segment.task.project return project.id if project else None @@ -524,8 +534,8 @@ class SourceType(str, Enum): MANUAL = 'manual' @classmethod - def choices(self): - return tuple((x.value, x.name) for x in self) + def choices(cls): + return tuple((x.value, x.name) for x in cls) def __str__(self): return self.value @@ -669,6 +679,21 @@ class Manifest(models.Model): def __str__(self): return '{}'.format(self.filename) +class Location(str, Enum): + CLOUD_STORAGE = 'cloud_storage' + LOCAL = 'local' + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + def __str__(self): + return self.value + + @classmethod + def list(cls): + return [i.value for i in cls] + class CloudStorage(models.Model): # restrictions: # AWS bucket name, Azure container name - 63, Google bucket name - 63 without dots and 222 with dots @@ -696,7 +721,6 @@ class CloudStorage(models.Model): organization = models.ForeignKey(Organization, null=True, default=None, blank=True, on_delete=models.SET_NULL, related_name="cloudstorages") - class Meta: default_permissions = () unique_together = ('provider_type', 'resource', 'credentials') @@ -721,3 +745,10 @@ class CloudStorage(models.Model): def get_key_file_path(self): return os.path.join(self.get_storage_dirname(), 'key.json') + +class Storage(models.Model): + location = models.CharField(max_length=16, choices=Location.choices(), default=Location.LOCAL) + cloud_storage_id = models.IntegerField(null=True, blank=True, default=None) + + class Meta: + default_permissions = () diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index ed01e5a7..2e95c357 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -20,7 +20,7 @@ from cvat.apps.engine.utils import parse_specific_attributes from drf_spectacular.utils import OpenApiExample, extend_schema_serializer class BasicUserSerializer(serializers.ModelSerializer): - def validate(self, data): + def validate(self, attrs): if hasattr(self, 'initial_data'): unknown_keys = set(self.initial_data.keys()) - set(self.fields.keys()) if unknown_keys: @@ -30,7 +30,7 @@ class BasicUserSerializer(serializers.ModelSerializer): else: message = 'Got unknown fields: {}'.format(unknown_keys) raise serializers.ValidationError(message) - return data + return attrs class Meta: model = User @@ -81,7 +81,7 @@ class LabelSerializer(serializers.ModelSerializer): fields = ('id', 'name', 'color', 'attributes', 'deleted') def validate(self, attrs): - if attrs.get('deleted') == True and attrs.get('id') is None: + if attrs.get('deleted') and attrs.get('id') is None: raise serializers.ValidationError('Deleted label must have an ID') return attrs @@ -107,7 +107,7 @@ class LabelSerializer(serializers.ModelSerializer): else: db_label = models.Label.objects.create(name=validated_data.get('name'), **instance) logger.info("New {} label was created".format(db_label.name)) - if validated_data.get('deleted') == True: + if validated_data.get('deleted'): db_label.delete() return if not validated_data.get('color', None): @@ -159,7 +159,8 @@ class JobReadSerializer(serializers.ModelSerializer): model = models.Job fields = ('url', 'id', 'task_id', 'project_id', 'assignee', 'dimension', 'labels', 'bug_tracker', 'status', 'stage', 'state', 'mode', - 'start_frame', 'stop_frame', 'data_chunk_size', 'data_compressed_chunk_type') + 'start_frame', 'stop_frame', 'data_chunk_size', 'data_compressed_chunk_type', + 'updated_date',) read_only_fields = fields class JobWriteSerializer(serializers.ModelSerializer): @@ -336,7 +337,7 @@ class DataSerializer(serializers.ModelSerializer): # pylint: disable=no-self-use def validate_frame_filter(self, value): - match = re.search("step\s*=\s*([1-9]\d*)", value) + match = re.search(r"step\s*=\s*([1-9]\d*)", value) if not match: raise serializers.ValidationError("Invalid frame filter expression") return value @@ -348,11 +349,11 @@ class DataSerializer(serializers.ModelSerializer): return value # pylint: disable=no-self-use - def validate(self, data): - if 'start_frame' in data and 'stop_frame' in data \ - and data['start_frame'] > data['stop_frame']: + def validate(self, attrs): + if 'start_frame' in attrs and 'stop_frame' in attrs \ + and attrs['start_frame'] > attrs['stop_frame']: raise serializers.ValidationError('Stop frame must be more or equal start frame') - return data + return attrs def create(self, validated_data): files = self._pop_data(validated_data) @@ -404,8 +405,12 @@ class DataSerializer(serializers.ModelSerializer): remote_file = models.RemoteFile(data=instance, **f) remote_file.save() +class StorageSerializer(serializers.ModelSerializer): + class Meta: + model = models.Storage + fields = ('id', 'location', 'cloud_storage_id') -class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): +class TaskReadSerializer(serializers.ModelSerializer): labels = LabelSerializer(many=True, source='label_set', partial=True, required=False) segments = SegmentSerializer(many=True, source='segment_set', read_only=True) data_chunk_size = serializers.ReadOnlyField(source='data.chunk_size') @@ -415,24 +420,47 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): image_quality = serializers.ReadOnlyField(source='data.image_quality') data = serializers.ReadOnlyField(source='data.id') owner = BasicUserSerializer(required=False) - owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) assignee = BasicUserSerializer(allow_null=True, required=False) - assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) project_id = serializers.IntegerField(required=False, allow_null=True) dimension = serializers.CharField(allow_blank=True, required=False) + target_storage = StorageSerializer(required=False) + source_storage = StorageSerializer(required=False) class Meta: model = models.Task fields = ('url', 'id', 'name', 'project_id', 'mode', 'owner', 'assignee', - 'owner_id', 'assignee_id', 'bug_tracker', 'created_date', 'updated_date', - 'overlap', 'segment_size', 'status', 'labels', 'segments', - 'data_chunk_size', 'data_compressed_chunk_type', 'data_original_chunk_type', - 'size', 'image_quality', 'data', 'dimension', 'subset', 'organization') - read_only_fields = ('mode', 'created_date', 'updated_date', 'status', - 'data_chunk_size', 'owner', 'assignee', 'data_compressed_chunk_type', - 'data_original_chunk_type', 'size', 'image_quality', 'data', - 'organization') - write_once_fields = ('overlap', 'segment_size', 'project_id') + 'bug_tracker', 'created_date', 'updated_date', 'overlap', 'segment_size', + 'status', 'labels', 'segments', 'data_chunk_size', 'data_compressed_chunk_type', + 'data_original_chunk_type', 'size', 'image_quality', 'data', 'dimension', + 'subset', 'organization', 'target_storage', 'source_storage', + ) + read_only_fields = fields + + def to_representation(self, instance): + response = super().to_representation(instance) + if instance.project_id: + response["labels"] = LabelSerializer(many=True).to_representation(instance.project.label_set) + return response + +class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): + labels = LabelSerializer(many=True, source='label_set', partial=True, required=False) + owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) + assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) + project_id = serializers.IntegerField(required=False, allow_null=True) + target_storage = StorageSerializer(required=False) + source_storage = StorageSerializer(required=False) + + class Meta: + model = models.Task + fields = ('url', 'id', 'name', 'project_id', 'owner_id', 'assignee_id', + 'bug_tracker', 'overlap', 'segment_size', 'labels', 'subset', + 'target_storage', 'source_storage', + ) + write_once_fields = ('overlap', 'segment_size', 'project_id', 'owner_id', 'labels') + + def to_representation(self, instance): + serializer = TaskReadSerializer(instance, context=self.context) + return serializer.data # pylint: disable=no-self-use def create(self, validated_data): @@ -453,7 +481,17 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): raise serializers.ValidationError(f'The task and its project should be in the same organization.') labels = validated_data.pop('label_set', []) - db_task = models.Task.objects.create(**validated_data) + + # configure source/target storages for import/export + storages = _configure_related_storages({ + 'source_storage': validated_data.pop('source_storage', None), + 'target_storage': validated_data.pop('target_storage', None), + }) + + db_task = models.Task.objects.create( + **storages, + **validated_data) + label_colors = list() for label in labels: attributes = label.pop('attributespec_set') @@ -468,7 +506,7 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): del attr['id'] models.AttributeSpec.objects.create(label=db_label, **attr) - task_path = db_task.get_task_dirname() + task_path = db_task.get_dirname() if os.path.isdir(task_path): shutil.rmtree(task_path) @@ -478,12 +516,6 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): db_task.save() return db_task - def to_representation(self, instance): - response = super().to_representation(instance) - if instance.project_id: - response["labels"] = LabelSerializer(many=True).to_representation(instance.project.label_set) - return response - # pylint: disable=no-self-use def update(self, instance, validated_data): instance.name = validated_data.get('name', instance.name) @@ -500,7 +532,7 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): if validated_project_id is not None and validated_project_id != instance.project_id: project = models.Project.objects.get(id=validated_project_id) if project.tasks.count() and project.tasks.first().dimension != instance.dimension: - raise serializers.ValidationError(f'Dimension ({instance.dimension}) of the task must be the same as other tasks in project ({project.tasks.first().dimension})') + raise serializers.ValidationError(f'Dimension ({instance.dimension}) of the task must be the same as other tasks in project ({project.tasks.first().dimension})') if instance.project_id is None: for old_label in instance.label_set.all(): try: @@ -536,6 +568,9 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): ) instance.project = project + # update source and target storages + _update_related_storages(instance, validated_data) + instance.save() return instance @@ -547,6 +582,7 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): project = models.Project.objects.filter(id=project_id).first() if project is None: raise serializers.ValidationError(f'Cannot find project with ID {project_id}') + # Check that all labels can be mapped new_label_names = set() old_labels = self.instance.project.label_set.all() if self.instance.project_id else self.instance.label_set.all() @@ -577,22 +613,26 @@ class ProjectSearchSerializer(serializers.ModelSerializer): fields = ('id', 'name') read_only_fields = ('name',) -class ProjectSerializer(serializers.ModelSerializer): +class ProjectReadSerializer(serializers.ModelSerializer): labels = LabelSerializer(many=True, source='label_set', partial=True, default=[]) owner = BasicUserSerializer(required=False, read_only=True) - owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) - assignee = BasicUserSerializer(allow_null=True, required=False) - assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) + assignee = BasicUserSerializer(allow_null=True, required=False, read_only=True) task_subsets = serializers.ListField(child=serializers.CharField(), required=False) dimension = serializers.CharField(max_length=16, required=False, read_only=True) + target_storage = StorageSerializer(required=False) + source_storage = StorageSerializer(required=False) class Meta: model = models.Project fields = ('url', 'id', 'name', 'labels', 'tasks', 'owner', 'assignee', - 'owner_id', 'assignee_id', 'bug_tracker', 'task_subsets', - 'created_date', 'updated_date', 'status', 'dimension', 'organization') + 'bug_tracker', 'task_subsets', # 'owner_id', 'assignee_id', + 'created_date', 'updated_date', 'status', 'dimension', 'organization', + 'target_storage', 'source_storage', + ) read_only_fields = ('created_date', 'updated_date', 'status', 'owner', - 'assignee', 'task_subsets', 'dimension', 'organization', 'tasks') + 'assignee', 'task_subsets', 'dimension', 'organization', 'tasks', + 'target_storage', 'source_storage', + ) def to_representation(self, instance): response = super().to_representation(instance) @@ -602,10 +642,38 @@ class ProjectSerializer(serializers.ModelSerializer): response['dimension'] = instance.tasks.first().dimension if instance.tasks.count() else None return response +class ProjectWriteSerializer(serializers.ModelSerializer): + labels = LabelSerializer(many=True, source='label_set', partial=True, default=[]) + owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) + assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) + task_subsets = serializers.ListField(child=serializers.CharField(), required=False) + + target_storage = StorageSerializer(required=False) + source_storage = StorageSerializer(required=False) + + class Meta: + model = models.Project + fields = ('name', 'labels', 'owner_id', 'assignee_id', 'bug_tracker', + 'target_storage', 'source_storage', 'task_subsets', + ) + + def to_representation(self, instance): + serializer = ProjectReadSerializer(instance, context=self.context) + return serializer.data + # pylint: disable=no-self-use def create(self, validated_data): labels = validated_data.pop('label_set') - db_project = models.Project.objects.create(**validated_data) + + # configure source/target storages for import/export + storages = _configure_related_storages({ + 'source_storage': validated_data.pop('source_storage', None), + 'target_storage': validated_data.pop('target_storage', None), + }) + + db_project = models.Project.objects.create( + **storages, + **validated_data) label_colors = list() for label in labels: if label.get('id', None): @@ -620,7 +688,7 @@ class ProjectSerializer(serializers.ModelSerializer): del attr['id'] models.AttributeSpec.objects.create(label=db_label, **attr) - project_path = db_project.get_project_dirname() + project_path = db_project.get_dirname() if os.path.isdir(project_path): shutil.rmtree(project_path) os.makedirs(db_project.get_project_logs_dirname()) @@ -637,6 +705,9 @@ class ProjectSerializer(serializers.ModelSerializer): for label in labels: LabelSerializer.update_instance(label, instance) + # update source and target storages + _update_related_storages(instance, validated_data) + instance.save() return instance @@ -976,19 +1047,19 @@ class CloudStorageWriteSerializer(serializers.ModelSerializer): @staticmethod def _manifests_validation(storage, manifests): - # check manifest files availability - for manifest in manifests: - file_status = storage.get_file_status(manifest) - if file_status == Status.NOT_FOUND: - raise serializers.ValidationError({ - 'manifests': "The '{}' file does not exist on '{}' cloud storage" \ - .format(manifest, storage.name) - }) - elif file_status == Status.FORBIDDEN: - raise serializers.ValidationError({ - 'manifests': "The '{}' file does not available on '{}' cloud storage. Access denied" \ - .format(manifest, storage.name) - }) + # check manifest files availability + for manifest in manifests: + file_status = storage.get_file_status(manifest) + if file_status == Status.NOT_FOUND: + raise serializers.ValidationError({ + 'manifests': "The '{}' file does not exist on '{}' cloud storage" \ + .format(manifest, storage.name) + }) + elif file_status == Status.FORBIDDEN: + raise serializers.ValidationError({ + 'manifests': "The '{}' file does not available on '{}' cloud storage. Access denied" \ + .format(manifest, storage.name) + }) def create(self, validated_data): provider_type = validated_data.get('provider_type') @@ -1140,3 +1211,61 @@ class RelatedFileSerializer(serializers.ModelSerializer): model = models.RelatedFile fields = '__all__' read_only_fields = ('path',) + + +def _update_related_storages(instance, validated_data): + for storage in ('source_storage', 'target_storage'): + new_conf = validated_data.pop(storage, None) + + if not new_conf: + continue + + cloud_storage_id = new_conf.get('cloud_storage_id') + if cloud_storage_id: + _validate_existence_of_cloud_storage(cloud_storage_id) + + # storage_instance maybe None + storage_instance = getattr(instance, storage) + if not storage_instance: + storage_instance = models.Storage(**new_conf) + storage_instance.save() + setattr(instance, storage, storage_instance) + continue + + new_location = new_conf.get('location') + storage_instance.location = new_location or storage_instance.location + storage_instance.cloud_storage_id = new_conf.get('cloud_storage_id', \ + storage_instance.cloud_storage_id if not new_location else None) + + cloud_storage_id = storage_instance.cloud_storage_id + if cloud_storage_id: + try: + _ = models.CloudStorage.objects.get(id=cloud_storage_id) + except models.CloudStorage.DoesNotExist: + raise serializers.ValidationError(f'The specified cloud storage {cloud_storage_id} does not exist.') + + storage_instance.save() + +def _configure_related_storages(validated_data): + + storages = { + 'source_storage': None, + 'target_storage': None, + } + + for i in storages: + storage_conf = validated_data.get(i) + if storage_conf: + cloud_storage_id = storage_conf.get('cloud_storage_id') + if cloud_storage_id: + _validate_existence_of_cloud_storage(cloud_storage_id) + storage_instance = models.Storage(**storage_conf) + storage_instance.save() + storages[i] = storage_instance + return storages + +def _validate_existence_of_cloud_storage(cloud_storage_id): + try: + _ = models.CloudStorage.objects.get(id=cloud_storage_id) + except models.CloudStorage.DoesNotExist: + raise serializers.ValidationError(f'The specified cloud storage {cloud_storage_id} does not exist.') diff --git a/cvat/apps/engine/signals.py b/cvat/apps/engine/signals.py index 5ef6e5f3..c86c58e5 100644 --- a/cvat/apps/engine/signals.py +++ b/cvat/apps/engine/signals.py @@ -39,7 +39,7 @@ def create_profile(instance, **kwargs): @receiver(post_delete, sender=Task, dispatch_uid="delete_task_files_on_delete_task") def delete_task_files_on_delete_task(instance, **kwargs): - shutil.rmtree(instance.get_task_dirname(), ignore_errors=True) + shutil.rmtree(instance.get_dirname(), ignore_errors=True) @receiver(post_delete, sender=Data, dispatch_uid="delete_data_files_on_delete_data") diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 33464493..5267010c 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -112,6 +112,12 @@ def _save_task_to_db(db_task): db_job = models.Job(segment=db_segment) db_job.save() + # create job directory + job_path = db_job.get_dirname() + if os.path.isdir(job_path): + shutil.rmtree(job_path) + os.makedirs(job_path) + db_task.data.save() db_task.save() @@ -489,7 +495,7 @@ def _create_thread(db_task, data, isBackupRestore=False, isDatasetImport=False): # calculate chunk size if it isn't specified if db_data.chunk_size is None: if isinstance(compressed_chunk_writer, ZipCompressedChunkWriter): - if not (db_data.storage == models.StorageChoice.CLOUD_STORAGE): + if not db_data.storage == models.StorageChoice.CLOUD_STORAGE: w, h = extractor.get_image_size(0) else: img_properties = manifest[0] @@ -507,7 +513,7 @@ def _create_thread(db_task, data, isBackupRestore=False, isDatasetImport=False): job.save_meta() if settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE: - for media_type, media_files in media.items(): + for media_type, media_files in media.items(): if not media_files: continue diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 1059dc1f..bdc0787f 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -80,8 +80,8 @@ def create_db_task(data): labels = data.pop('labels', None) db_task = Task.objects.create(**data) - shutil.rmtree(db_task.get_task_dirname(), ignore_errors=True) - os.makedirs(db_task.get_task_dirname()) + shutil.rmtree(db_task.get_dirname(), ignore_errors=True) + os.makedirs(db_task.get_dirname()) os.makedirs(db_task.get_task_logs_dirname()) os.makedirs(db_task.get_task_artifacts_dirname()) db_task.data = db_data @@ -117,8 +117,8 @@ def create_db_task(data): def create_db_project(data): labels = data.pop('labels', None) db_project = Project.objects.create(**data) - shutil.rmtree(db_project.get_project_dirname(), ignore_errors=True) - os.makedirs(db_project.get_project_dirname()) + shutil.rmtree(db_project.get_dirname(), ignore_errors=True) + os.makedirs(db_project.get_dirname()) os.makedirs(db_project.get_project_logs_dirname()) if not labels is None: @@ -1979,11 +1979,11 @@ class TaskDeleteAPITestCase(APITestCase): def test_api_v2_tasks_delete_task_data_after_delete_task(self): for task in self.tasks: - task_dir = task.get_task_dirname() + task_dir = task.get_dirname() self.assertTrue(os.path.exists(task_dir)) self._check_api_v2_tasks_id(self.admin) for task in self.tasks: - task_dir = task.get_task_dirname() + task_dir = task.get_dirname() self.assertFalse(os.path.exists(task_dir)) class TaskUpdateAPITestCase(APITestCase): @@ -2418,7 +2418,7 @@ class TaskMoveAPITestCase(APITestCase): def _check_api_v2_tasks(self, tid, data, expected_status=status.HTTP_200_OK): response = self._run_api_v2_tasks_id(tid, data) self.assertEqual(response.status_code, expected_status) - if (expected_status == status.HTTP_200_OK): + if expected_status == status.HTTP_200_OK: self._check_response(response, data) def test_move_task_bad_request(self): @@ -2936,6 +2936,8 @@ class TaskImportExportAPITestCase(APITestCase): "created_date", "updated_date", "data", + "source_storage", + "target_storage", ), ) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 2999f718..d48f4136 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -15,6 +15,7 @@ from tempfile import mkstemp, NamedTemporaryFile import cv2 from django.db.models.query import Prefetch +from django.shortcuts import get_object_or_404 import django_rq from django.apps import apps from django.conf import settings @@ -40,7 +41,8 @@ from django_sendfile import sendfile import cvat.apps.dataset_manager as dm import cvat.apps.dataset_manager.views # pylint: disable=unused-import -from cvat.apps.engine.cloud_provider import get_cloud_storage_instance, Credentials, Status as CloudStorageStatus +from cvat.apps.engine.cloud_provider import ( + db_storage_to_storage_instance, validate_bucket_status, Status as CloudStorageStatus) from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer from cvat.apps.engine.frame_provider import FrameProvider @@ -49,22 +51,23 @@ from cvat.apps.engine.mime_types import mimetypes from cvat.apps.engine.models import ( Job, Task, Project, Issue, Data, Comment, StorageMethodChoice, StorageChoice, Image, - CloudProviderChoice + CloudProviderChoice, Location ) from cvat.apps.engine.models import CloudStorage as CloudStorageModel from cvat.apps.engine.serializers import ( AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, DataMetaReadSerializer, DataMetaWriteSerializer, DataSerializer, ExceptionSerializer, FileInfoSerializer, JobReadSerializer, JobWriteSerializer, LabeledDataSerializer, - LogEventSerializer, ProjectSerializer, ProjectSearchSerializer, - RqStatusSerializer, TaskSerializer, UserSerializer, PluginsSerializer, IssueReadSerializer, + LogEventSerializer, ProjectReadSerializer, ProjectWriteSerializer, ProjectSearchSerializer, + RqStatusSerializer, TaskReadSerializer, TaskWriteSerializer, UserSerializer, PluginsSerializer, IssueReadSerializer, IssueWriteSerializer, CommentReadSerializer, CommentWriteSerializer, CloudStorageWriteSerializer, - CloudStorageReadSerializer, DatasetFileSerializer, JobCommitSerializer) + CloudStorageReadSerializer, DatasetFileSerializer, JobCommitSerializer, + ProjectFileSerializer, TaskFileSerializer) from utils.dataset_manifest import ImageManifestManager from cvat.apps.engine.utils import av_scan_paths from cvat.apps.engine import backup -from cvat.apps.engine.mixins import UploadMixin +from cvat.apps.engine.mixins import UploadMixin, AnnotationMixin, SerializeMixin from . import models, task from .log import clogger, slogger @@ -72,6 +75,7 @@ from cvat.apps.iam.permissions import (CloudStoragePermission, CommentPermission, IssuePermission, JobPermission, ProjectPermission, TaskPermission, UserPermission) + @extend_schema(tags=['server']) class ServerViewSet(viewsets.ViewSet): serializer_class = None @@ -226,18 +230,18 @@ class ServerViewSet(viewsets.ViewSet): responses={ '200': PolymorphicProxySerializer(component_name='PolymorphicProject', serializers=[ - ProjectSerializer, ProjectSearchSerializer, + ProjectReadSerializer, ProjectSearchSerializer, ], resource_type_field_name='name', many=True), }), create=extend_schema( summary='Method creates a new project', responses={ - '201': ProjectSerializer, + '201': ProjectWriteSerializer, }), retrieve=extend_schema( summary='Method returns details of a specific project', responses={ - '200': ProjectSerializer, + '200': ProjectReadSerializer, }), destroy=extend_schema( summary='Method deletes a specific project', @@ -247,10 +251,10 @@ class ServerViewSet(viewsets.ViewSet): partial_update=extend_schema( summary='Methods does a partial update of chosen fields in a project', responses={ - '200': ProjectSerializer, + '200': ProjectWriteSerializer, }) ) -class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): +class ProjectViewSet(viewsets.ModelViewSet, UploadMixin, AnnotationMixin, SerializeMixin): queryset = models.Project.objects.prefetch_related(Prefetch('label_set', queryset=models.Label.objects.order_by('id') )) @@ -267,9 +271,12 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): def get_serializer_class(self): if self.request.path.endswith('tasks'): - return TaskSerializer + return TaskReadSerializer else: - return ProjectSerializer + if self.request.method in SAFE_METHODS: + return ProjectReadSerializer + else: + return ProjectWriteSerializer def get_queryset(self): queryset = super().get_queryset() @@ -285,9 +292,9 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): @extend_schema( summary='Method returns information of the tasks of the project with the selected id', responses={ - '200': TaskSerializer(many=True), + '200': TaskReadSerializer(many=True), }) - @action(detail=True, methods=['GET'], serializer_class=TaskSerializer) + @action(detail=True, methods=['GET'], serializer_class=TaskReadSerializer) def tasks(self, request, pk): self.get_object() # force to call check_object_permissions queryset = Task.objects.filter(project_id=pk).order_by('-id') @@ -311,7 +318,15 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): OpenApiParameter('filename', description='Desired output file name', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), OpenApiParameter('action', description='Used to start downloading process after annotation file had been created', - location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, enum=['download', 'import_status']) + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, enum=['download', 'import_status']), + OpenApiParameter('location', description='Where need to save downloaded dataset', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in project to import dataset', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), ], responses={ '200': OpenApiResponse(description='Download of file started'), @@ -323,7 +338,17 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): parameters=[ OpenApiParameter('format', description='Desired dataset format name\n' 'You can get the list of supported formats at:\n/server/annotation/formats', - location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True) + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True), + OpenApiParameter('location', description='Where to import the dataset from', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in the project to import annotations', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + OpenApiParameter('filename', description='Dataset file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), ], responses={ '202': OpenApiResponse(description='Exporting has been started'), @@ -335,8 +360,15 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): def dataset(self, request, pk): self._object = self.get_object() # force to call check_object_permissions - if request.method == 'POST' or request.method == 'OPTIONS': - return self.upload_data(request) + if request.method in {'POST', 'OPTIONS'}: + + return self.import_annotations( + request=request, + pk=pk, + db_obj=self._object, + import_func=_import_project_dataset, + rq_func=dm.project.import_dataset_as_project + ) else: action = request.query_params.get("action", "").lower() if action in ("import_status",): @@ -363,15 +395,12 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): status=status.HTTP_202_ACCEPTED ) else: - format_name = request.query_params.get("format", "") - return _export_annotations( - db_instance=self._object, - rq_id="/api/project/{}/dataset/{}".format(pk, format_name), + return self.export_annotations( request=request, - action=action, - callback=dm.views.export_project_as_dataset, - format_name=format_name, - filename=request.query_params.get("filename", "").lower(), + pk=pk, + db_obj=self._object, + export_func=_export_annotations, + callback=dm.views.export_project_as_dataset ) @action(detail=True, methods=['HEAD', 'PATCH'], url_path='dataset/'+UploadMixin.file_id_regex) @@ -423,7 +452,15 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): OpenApiParameter('filename', description='Desired output file name', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), OpenApiParameter('action', description='Used to start downloading process after annotation file had been created', - location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, enum=['download']) + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, enum=['download']), + OpenApiParameter('location', description='Where need to save downloaded dataset', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in project to export annotation', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), ], responses={ '200': OpenApiResponse(description='Download of file started'), @@ -435,21 +472,30 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): @action(detail=True, methods=['GET'], serializer_class=LabeledDataSerializer) def annotations(self, request, pk): - db_project = self.get_object() # force to call check_object_permissions - format_name = request.query_params.get('format') - if format_name: - return _export_annotations(db_instance=db_project, - rq_id="/api/projects/{}/annotations/{}".format(pk, format_name), - request=request, - action=request.query_params.get("action", "").lower(), - callback=dm.views.export_project_annotations, - format_name=format_name, - filename=request.query_params.get("filename", "").lower(), - ) - else: - return Response("Format is not specified",status=status.HTTP_400_BAD_REQUEST) + self._object = self.get_object() # force to call check_object_permissions + return self.export_annotations( + request=request, + pk=pk, + db_obj=self._object, + export_func=_export_annotations, + callback=dm.views.export_project_annotations, + get_data=dm.task.get_job_data, + ) @extend_schema(summary='Methods creates a backup copy of a project', + parameters=[ + OpenApiParameter('action', location=OpenApiParameter.QUERY, + description='Used to start downloading process after backup file had been created', + type=OpenApiTypes.STR, required=False, enum=['download']), + OpenApiParameter('location', description='Where need to save downloaded backup', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in project to export backup', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + ], responses={ '200': OpenApiResponse(description='Download of file started'), '201': OpenApiResponse(description='Output backup file is ready for downloading'), @@ -457,17 +503,26 @@ class ProjectViewSet(viewsets.ModelViewSet, UploadMixin): }) @action(methods=['GET'], detail=True, url_path='backup') def export_backup(self, request, pk=None): - db_project = self.get_object() # force to call check_object_permissions - return backup.export(db_project, request) + return self.serialize(request, backup.export) @extend_schema(summary='Methods create a project from a backup', + parameters=[ + OpenApiParameter('location', description='Where to import the backup file from', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list(), default=Location.LOCAL), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('filename', description='Backup file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + ], + request=ProjectFileSerializer(required=False), responses={ '201': OpenApiResponse(description='The project has been imported'), # or better specify {id: project_id} '202': OpenApiResponse(description='Importing a backup file has been started'), }) - @action(detail=False, methods=['OPTIONS', 'POST'], url_path=r'backup/?$') + @action(detail=False, methods=['OPTIONS', 'POST'], url_path=r'backup/?$', serializer_class=ProjectFileSerializer(required=False)) def import_backup(self, request, pk=None): - return self.upload_data(request) + return self.deserialize(request, backup.import_project) @action(detail=False, methods=['HEAD', 'PATCH'], url_path='backup/'+UploadMixin.file_id_regex) def append_backup_chunk(self, request, file_id): @@ -522,6 +577,7 @@ class DataChunkGetter: if self.type == 'chunk': start_chunk = frame_provider.get_chunk_number(start) stop_chunk = frame_provider.get_chunk_number(stop) + # pylint: disable=superfluous-parens if not (start_chunk <= self.number <= stop_chunk): raise ValidationError('The chunk number should be in ' + f'[{start_chunk}, {stop_chunk}] range') @@ -571,20 +627,20 @@ class DataChunkGetter: list=extend_schema( summary='Returns a paginated list of tasks according to query parameters (10 tasks per page)', responses={ - '200': TaskSerializer(many=True), + '200': TaskReadSerializer(many=True), }), create=extend_schema( summary='Method creates a new task in a database without any attached images and videos', responses={ - '201': TaskSerializer, + '201': TaskWriteSerializer, }), retrieve=extend_schema( summary='Method returns details of a specific task', - responses=TaskSerializer), + responses=TaskReadSerializer), update=extend_schema( summary='Method updates a task by id', responses={ - '200': TaskSerializer, + '200': TaskWriteSerializer, }), destroy=extend_schema( summary='Method deletes a specific task, all attached jobs, annotations, and data', @@ -594,15 +650,14 @@ class DataChunkGetter: partial_update=extend_schema( summary='Methods does a partial update of chosen fields in a task', responses={ - '200': TaskSerializer, + '200': TaskWriteSerializer, }) ) -class TaskViewSet(UploadMixin, viewsets.ModelViewSet): +class TaskViewSet(UploadMixin, AnnotationMixin, viewsets.ModelViewSet, SerializeMixin): queryset = Task.objects.prefetch_related( Prefetch('label_set', queryset=models.Label.objects.order_by('id')), "label_set__attributespec_set", "segment_set__job_set") - serializer_class = TaskSerializer lookup_fields = {'project_name': 'project__name', 'owner': 'owner__username', 'assignee': 'assignee__username'} search_fields = ('project_name', 'name', 'owner', 'status', 'assignee', 'subset', 'mode', 'dimension') filter_fields = list(search_fields) + ['id', 'project_id', 'updated_date'] @@ -610,6 +665,12 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): ordering = "-id" iam_organization_field = 'organization' + def get_serializer_class(self): + if self.request.method in SAFE_METHODS: + return TaskReadSerializer + else: + return TaskWriteSerializer + def get_queryset(self): queryset = super().get_queryset() if self.action == 'list': @@ -619,19 +680,42 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): return queryset @extend_schema(summary='Method recreates a task from an attached task backup file', + parameters=[ + OpenApiParameter('location', description='Where to import the backup file from', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list(), default=Location.LOCAL), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('filename', description='Backup file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + ], + request=TaskFileSerializer(required=False), responses={ '201': OpenApiResponse(description='The task has been imported'), # or better specify {id: task_id} '202': OpenApiResponse(description='Importing a backup file has been started'), }) - @action(detail=False, methods=['OPTIONS', 'POST'], url_path=r'backup/?$') + @action(detail=False, methods=['OPTIONS', 'POST'], url_path=r'backup/?$', serializer_class=TaskFileSerializer(required=False)) def import_backup(self, request, pk=None): - return self.upload_data(request) + return self.deserialize(request, backup.import_task) @action(detail=False, methods=['HEAD', 'PATCH'], url_path='backup/'+UploadMixin.file_id_regex) def append_backup_chunk(self, request, file_id): return self.append_tus_chunk(request, file_id) @extend_schema(summary='Method backup a specified task', + parameters=[ + OpenApiParameter('action', location=OpenApiParameter.QUERY, + description='Used to start downloading process after backup file had been created', + type=OpenApiTypes.STR, required=False, enum=['download']), + OpenApiParameter('location', description='Where need to save downloaded backup', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in the task to export backup', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + ], responses={ '200': OpenApiResponse(description='Download of file started'), '201': OpenApiResponse(description='Output backup file is ready for downloading'), @@ -639,8 +723,7 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): }) @action(methods=['GET'], detail=True, url_path='backup') def export_backup(self, request, pk=None): - db_task = self.get_object() # force to call check_object_permissions - return backup.export(db_task, request) + return self.serialize(request, backup.export) def perform_update(self, serializer): instance = serializer.instance @@ -659,7 +742,7 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): assert instance.organization == db_project.organization def perform_destroy(self, instance): - task_dirname = instance.get_task_dirname() + task_dirname = instance.get_dirname() super().perform_destroy(instance) shutil.rmtree(task_dirname, ignore_errors=True) if instance.data and not instance.data.tasks.all(): @@ -695,6 +778,7 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): # UploadMixin method def upload_finished(self, request): if self.action == 'annotations': + # db_task = self.get_object() format_name = request.query_params.get("format", "") filename = request.query_params.get("filename", "") tmp_dir = self._object.get_tmp_dirname() @@ -823,7 +907,15 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), OpenApiParameter('action', location=OpenApiParameter.QUERY, description='Used to start downloading process after annotation file had been created', - type=OpenApiTypes.STR, required=False, enum=['download']) + type=OpenApiTypes.STR, required=False, enum=['download']), + OpenApiParameter('location', description='Where need to save downloaded dataset', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in the task to export annotation', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), ], responses={ '200': OpenApiResponse(description='Download of file started'), @@ -841,6 +933,26 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): '202': OpenApiResponse(description='Uploading has been started'), '405': OpenApiResponse(description='Format is not available'), }) + @extend_schema(methods=['POST'], summary='Method allows to upload task annotations from storage', + parameters=[ + OpenApiParameter('format', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + description='Input format name\nYou can get the list of supported formats at:\n/server/annotation/formats'), + OpenApiParameter('location', description='where to import the annotation from', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in task to import annotations', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + OpenApiParameter('filename', description='Annotation file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + ], + responses={ + '201': OpenApiResponse(description='Uploading has finished'), + '202': OpenApiResponse(description='Uploading has been started'), + '405': OpenApiResponse(description='Format is not available'), + }) @extend_schema(methods=['PATCH'], summary='Method performs a partial update of annotations in a specific task', parameters=[ OpenApiParameter('action', location=OpenApiParameter.QUERY, required=True, @@ -851,27 +963,26 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): '204': OpenApiResponse(description='The annotation has been deleted'), }) @action(detail=True, methods=['GET', 'DELETE', 'PUT', 'PATCH', 'POST', 'OPTIONS'], url_path=r'annotations/?$', - serializer_class=LabeledDataSerializer) + serializer_class=LabeledDataSerializer(required=False)) def annotations(self, request, pk): self._object = self.get_object() # force to call check_object_permissions if request.method == 'GET': - format_name = request.query_params.get('format') - if format_name: - return _export_annotations(db_instance=self._object, - rq_id="/api/tasks/{}/annotations/{}".format(pk, format_name), - request=request, - action=request.query_params.get("action", "").lower(), - callback=dm.views.export_task_annotations, - format_name=format_name, - filename=request.query_params.get("filename", "").lower(), - ) - else: - data = dm.task.get_task_data(pk) - serializer = LabeledDataSerializer(data=data) - if serializer.is_valid(raise_exception=True): - return Response(serializer.data) + return self.export_annotations( + request=request, + pk=pk, + db_obj=self._object, + export_func=_export_annotations, + callback=dm.views.export_task_annotations, + get_data=dm.task.get_task_data, + ) elif request.method == 'POST' or request.method == 'OPTIONS': - return self.upload_data(request) + return self.import_annotations( + request=request, + pk=pk, + db_obj=self._object, + import_func=_import_annotations, + rq_func=dm.task.import_task_annotations, + ) elif request.method == 'PUT': format_name = request.query_params.get('format') if format_name: @@ -991,7 +1102,15 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), OpenApiParameter('action', location=OpenApiParameter.QUERY, description='Used to start downloading process after annotation file had been created', - type=OpenApiTypes.STR, required=False, enum=['download']) + type=OpenApiTypes.STR, required=False, enum=['download']), + OpenApiParameter('use_default_location', description='Use the location that was configured in task to export annotations', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + OpenApiParameter('location', description='Where need to save downloaded dataset', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), ], responses={ '200': OpenApiResponse(description='Download of file started'), @@ -1002,16 +1121,14 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): @action(detail=True, methods=['GET'], serializer_class=None, url_path='dataset') def dataset_export(self, request, pk): - db_task = self.get_object() # force to call check_object_permissions + self._object = self.get_object() # force to call check_object_permissions - format_name = request.query_params.get("format", "") - return _export_annotations(db_instance=db_task, - rq_id="/api/tasks/{}/dataset/{}".format(pk, format_name), + return self.export_annotations( request=request, - action=request.query_params.get("action", "").lower(), - callback=dm.views.export_task_as_dataset, - format_name=format_name, - filename=request.query_params.get("filename", "").lower(), + pk=pk, + db_obj=self._object, + export_func=_export_annotations, + callback=dm.views.export_task_as_dataset ) @extend_schema(tags=['jobs']) @@ -1038,7 +1155,7 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet): }) ) class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.UpdateModelMixin, UploadMixin): + mixins.RetrieveModelMixin, mixins.UpdateModelMixin, UploadMixin, AnnotationMixin): queryset = Job.objects.all() iam_organization_field = 'segment__task__organization' search_fields = ('task_name', 'project_name', 'assignee', 'state', 'stage') @@ -1051,7 +1168,6 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, 'project_id': 'segment__task__project_id', 'task_name': 'segment__task__name', 'project_name': 'segment__task__project__name', - 'updated_date': 'segment__task__updated_date', 'assignee': 'assignee__username' } @@ -1099,8 +1215,49 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, status=status.HTTP_400_BAD_REQUEST) @extend_schema(methods=['GET'], summary='Method returns annotations for a specific job', + parameters=[ + OpenApiParameter('format', location=OpenApiParameter.QUERY, + description='Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats', + type=OpenApiTypes.STR, required=False), + OpenApiParameter('filename', description='Desired output file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + OpenApiParameter('action', location=OpenApiParameter.QUERY, + description='Used to start downloading process after annotation file had been created', + type=OpenApiTypes.STR, required=False, enum=['download']), + OpenApiParameter('location', description='Where need to save downloaded annotation', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in the task to export annotation', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + ], + responses={ + '200': LabeledDataSerializer, + '201': OpenApiResponse(description='Output file is ready for downloading'), + '202': OpenApiResponse(description='Exporting has been started'), + '405': OpenApiResponse(description='Format is not available'), + }) + @extend_schema(methods=['POST'], summary='Method allows to upload job annotations', + parameters=[ + OpenApiParameter('format', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + description='Input format name\nYou can get the list of supported formats at:\n/server/annotation/formats'), + OpenApiParameter('location', description='where to import the annotation from', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + OpenApiParameter('use_default_location', description='Use the location that was configured in the task to import annotation', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + OpenApiParameter('filename', description='Annotation file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + ], responses={ - '200': LabeledDataSerializer(many=True), + '201': OpenApiResponse(description='Uploading has finished'), + '202': OpenApiResponse(description='Uploading has been started'), + '405': OpenApiResponse(description='Format is not available'), }) @extend_schema(methods=['PUT'], summary='Method performs an update of all annotations in a specific job', request=AnnotationFileSerializer, responses={ @@ -1126,10 +1283,24 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, def annotations(self, request, pk): self._object = self.get_object() # force to call check_object_permissions if request.method == 'GET': - data = dm.task.get_job_data(pk) - return Response(data) + return self.export_annotations( + request=request, + pk=pk, + db_obj=self._object.segment.task, + export_func=_export_annotations, + callback=dm.views.export_job_annotations, + get_data=dm.task.get_job_data, + ) + elif request.method == 'POST' or request.method == 'OPTIONS': - return self.upload_data(request) + return self.import_annotations( + request=request, + pk=pk, + db_obj=self._object.segment.task, + import_func=_import_annotations, + rq_func=dm.task.import_job_annotations, + ) + elif request.method == 'PUT': format_name = request.query_params.get('format', '') if format_name: @@ -1169,6 +1340,44 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, self._object = self.get_object() return self.append_tus_chunk(request, file_id) + @extend_schema(summary='Export job as a dataset in a specific format', + parameters=[ + OpenApiParameter('format', location=OpenApiParameter.QUERY, + description='Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats', + type=OpenApiTypes.STR, required=True), + OpenApiParameter('filename', description='Desired output file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + OpenApiParameter('action', location=OpenApiParameter.QUERY, + description='Used to start downloading process after annotation file had been created', + type=OpenApiTypes.STR, required=False, enum=['download']), + OpenApiParameter('use_default_location', description='Use the location that was configured in the task to export dataset', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, required=False, + default=True), + OpenApiParameter('location', description='Where need to save downloaded dataset', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + enum=Location.list()), + OpenApiParameter('cloud_storage_id', description='Storage id', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, required=False), + ], + responses={ + '200': OpenApiResponse(description='Download of file started'), + '201': OpenApiResponse(description='Output file is ready for downloading'), + '202': OpenApiResponse(description='Exporting has been started'), + '405': OpenApiResponse(description='Format is not available'), + }) + @action(detail=True, methods=['GET'], serializer_class=None, + url_path='dataset') + def dataset_export(self, request, pk): + self._object = self.get_object() # force to call check_object_permissions + + return self.export_annotations( + request=request, + pk=pk, + db_obj=self._object.segment.task, + export_func=_export_annotations, + callback=dm.views.export_job_as_dataset + ) + @extend_schema( summary='Method returns list of issues for the job', responses={ @@ -1589,12 +1798,12 @@ class CloudStorageViewSet(viewsets.ModelViewSet): except IntegrityError: response = HttpResponseBadRequest('Same storage already exists') except ValidationError as exceptions: - msg_body = "" - for ex in exceptions.args: - for field, ex_msg in ex.items(): - msg_body += ': '.join([field, ex_msg if isinstance(ex_msg, str) else str(ex_msg[0])]) - msg_body += '\n' - return HttpResponseBadRequest(msg_body) + msg_body = "" + for ex in exceptions.args: + for field, ex_msg in ex.items(): + msg_body += ': '.join([field, ex_msg if isinstance(ex_msg, str) else str(ex_msg[0])]) + msg_body += '\n' + return HttpResponseBadRequest(msg_body) except APIException as ex: return Response(data=ex.get_full_details(), status=ex.status_code) except Exception as ex: @@ -1614,17 +1823,7 @@ class CloudStorageViewSet(viewsets.ModelViewSet): storage = None try: db_storage = self.get_object() - credentials = Credentials() - credentials.convert_from_db({ - 'type': db_storage.credentials_type, - 'value': db_storage.credentials, - }) - details = { - 'resource': db_storage.resource, - 'credentials': credentials, - 'specific_attributes': db_storage.get_specific_attributes() - } - storage = get_cloud_storage_instance(cloud_provider=db_storage.provider_type, **details) + storage = db_storage_to_storage_instance(db_storage) if not db_storage.manifests.count(): raise Exception('There is no manifest file') manifest_path = request.query_params.get('manifest_path', db_storage.manifests.first().filename) @@ -1675,17 +1874,7 @@ class CloudStorageViewSet(viewsets.ModelViewSet): try: db_storage = self.get_object() if not os.path.exists(db_storage.get_preview_path()): - credentials = Credentials() - credentials.convert_from_db({ - 'type': db_storage.credentials_type, - 'value': db_storage.credentials, - }) - details = { - 'resource': db_storage.resource, - 'credentials': credentials, - 'specific_attributes': db_storage.get_specific_attributes() - } - storage = get_cloud_storage_instance(cloud_provider=db_storage.provider_type, **details) + storage = db_storage_to_storage_instance(db_storage) if not db_storage.manifests.count(): raise Exception('Cannot get the cloud storage preview. There is no manifest file') preview_path = None @@ -1749,17 +1938,7 @@ class CloudStorageViewSet(viewsets.ModelViewSet): def status(self, request, pk): try: db_storage = self.get_object() - credentials = Credentials() - credentials.convert_from_db({ - 'type': db_storage.credentials_type, - 'value': db_storage.credentials, - }) - details = { - 'resource': db_storage.resource, - 'credentials': credentials, - 'specific_attributes': db_storage.get_specific_attributes() - } - storage = get_cloud_storage_instance(cloud_provider=db_storage.provider_type, **details) + storage = db_storage_to_storage_instance(db_storage) storage_status = storage.get_status() return HttpResponse(storage_status) except CloudStorageModel.DoesNotExist: @@ -1770,6 +1949,28 @@ class CloudStorageViewSet(viewsets.ModelViewSet): msg = str(ex) return HttpResponseBadRequest(msg) + @extend_schema(summary='Method returns allowed actions for the cloud storage', + responses={ + '200': OpenApiResponse(response=OpenApiTypes.STR, description='Cloud Storage actions (GET | PUT | DELETE)'), + }) + @action(detail=True, methods=['GET'], url_path='actions') + def actions(self, request, pk): + ''' + Method return allowed actions for cloud storage. It's required for reading/writing + ''' + try: + db_storage = self.get_object() + storage = db_storage_to_storage_instance(db_storage) + actions = storage.supported_actions + return Response(actions, content_type="text/plain") + except CloudStorageModel.DoesNotExist: + message = f"Storage {pk} does not exist" + slogger.glob.error(message) + return HttpResponseNotFound(message) + except Exception as ex: + msg = str(ex) + return HttpResponseBadRequest(msg) + def rq_handler(job, exc_type, exc_value, tb): job.exc_info = "".join( traceback.format_exception_only(exc_type, exc_value)) @@ -1779,7 +1980,16 @@ def rq_handler(job, exc_type, exc_value, tb): return True -def _import_annotations(request, rq_id, rq_func, pk, format_name, filename=None): +@validate_bucket_status +def _export_to_cloud_storage(storage, file_path, file_name): + storage.upload_file(file_path, file_name) + +@validate_bucket_status +def _import_from_cloud_storage(storage, file_name): + return storage.download_fileobj(file_name) + +def _import_annotations(request, rq_id, rq_func, pk, format_name, + filename=None, location_conf=None): format_desc = {f.DISPLAY_NAME: f for f in dm.views.get_import_formats()}.get(format_name) if format_desc is None: @@ -1794,15 +2004,36 @@ def _import_annotations(request, rq_id, rq_func, pk, format_name, filename=None) if not rq_job: # If filename is specified we consider that file was uploaded via TUS, so it exists in filesystem # Then we dont need to create temporary file + # Or filename specify key in cloud storage so we need to download file fd = None - if not filename: - serializer = AnnotationFileSerializer(data=request.data) - if serializer.is_valid(raise_exception=True): - anno_file = serializer.validated_data['annotation_file'] - fd, filename = mkstemp(prefix='cvat_{}'.format(pk)) + location = location_conf.get('location') if location_conf else Location.LOCAL + + if not filename or location == Location.CLOUD_STORAGE: + if location != Location.CLOUD_STORAGE: + serializer = AnnotationFileSerializer(data=request.data) + if serializer.is_valid(raise_exception=True): + anno_file = serializer.validated_data['annotation_file'] + fd, filename = mkstemp(prefix='cvat_{}'.format(pk)) + with open(filename, 'wb+') as f: + for chunk in anno_file.chunks(): + f.write(chunk) + else: + # download annotation file from cloud storage + try: + storage_id = location_conf['storage_id'] + except KeyError: + raise serializer.ValidationError( + 'Cloud storage location was selected for destination' + ' but cloud storage id was not specified') + db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) + storage = db_storage_to_storage_instance(db_storage) + assert filename, 'filename was not spesified' + + data = _import_from_cloud_storage(storage, filename) + + fd, filename = mkstemp(prefix='cvat_') with open(filename, 'wb+') as f: - for chunk in anno_file.chunks(): - f.write(chunk) + f.write(data.getbuffer()) av_scan_paths(filename) rq_job = queue.enqueue_call( @@ -1838,7 +2069,8 @@ def _import_annotations(request, rq_id, rq_func, pk, format_name, filename=None) return Response(status=status.HTTP_202_ACCEPTED) -def _export_annotations(db_instance, rq_id, request, format_name, action, callback, filename): +def _export_annotations(db_instance, rq_id, request, format_name, action, callback, + filename, location_conf): if action not in {"", "download"}: raise serializers.ValidationError( "Unexpected action specified for the request") @@ -1873,12 +2105,31 @@ def _export_annotations(db_instance, rq_id, request, format_name, action, callba "%Y_%m_%d_%H_%M_%S") filename = filename or \ "{}_{}-{}-{}{}".format( - "project" if isinstance(db_instance, models.Project) else "task", - db_instance.name, timestamp, - format_name, osp.splitext(file_path)[1] + db_instance.__class__.__name__.lower(), + db_instance.name if isinstance(db_instance, (Task, Project)) else db_instance.id, + timestamp, format_name, osp.splitext(file_path)[1] ) - return sendfile(request, file_path, attachment=True, - attachment_filename=filename.lower()) + + # save annotation to specified location + location = location_conf.get('location') + if location == Location.LOCAL: + return sendfile(request, file_path, attachment=True, + attachment_filename=filename.lower()) + elif location == Location.CLOUD_STORAGE: + try: + storage_id = location_conf['storage_id'] + except KeyError: + return HttpResponseBadRequest( + 'Cloud storage location was selected for destination' + ' but cloud storage id was not specified') + + db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) + storage = db_storage_to_storage_instance(db_storage) + + _export_to_cloud_storage(storage, file_path, filename) + return Response(status=status.HTTP_200_OK) + else: + raise NotImplementedError() else: if osp.exists(file_path): return Response(status=status.HTTP_201_CREATED) @@ -1897,14 +2148,19 @@ def _export_annotations(db_instance, rq_id, request, format_name, action, callba except Exception: server_address = None - ttl = (dm.views.PROJECT_CACHE_TTL if isinstance(db_instance, Project) else dm.views.TASK_CACHE_TTL).total_seconds() + TTL_CONSTS = { + 'project': dm.views.PROJECT_CACHE_TTL, + 'task': dm.views.TASK_CACHE_TTL, + 'job': dm.views.JOB_CACHE_TTL, + } + ttl = TTL_CONSTS[db_instance.__class__.__name__.lower()].total_seconds() queue.enqueue_call(func=callback, args=(db_instance.id, format_name, server_address), job_id=rq_id, meta={ 'request_time': timezone.localtime() }, result_ttl=ttl, failure_ttl=ttl) return Response(status=status.HTTP_202_ACCEPTED) -def _import_project_dataset(request, rq_id, rq_func, pk, format_name, filename=None): +def _import_project_dataset(request, rq_id, rq_func, pk, format_name, filename=None, location_conf=None): format_desc = {f.DISPLAY_NAME: f for f in dm.views.get_import_formats()}.get(format_name) if format_desc is None: @@ -1918,7 +2174,8 @@ def _import_project_dataset(request, rq_id, rq_func, pk, format_name, filename=N if not rq_job: fd = None - if not filename: + location = location_conf.get('location') if location_conf else None + if not filename and location != Location.CLOUD_STORAGE: serializer = DatasetFileSerializer(data=request.data) if serializer.is_valid(raise_exception=True): dataset_file = serializer.validated_data['dataset_file'] @@ -1926,6 +2183,24 @@ def _import_project_dataset(request, rq_id, rq_func, pk, format_name, filename=N with open(filename, 'wb+') as f: for chunk in dataset_file.chunks(): f.write(chunk) + elif location == Location.CLOUD_STORAGE: + assert filename + + # download project file from cloud storage + try: + storage_id = location_conf['storage_id'] + except KeyError: + raise serializers.ValidationError( + 'Cloud storage location was selected for destination' + ' but cloud storage id was not specified') + db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) + storage = db_storage_to_storage_instance(db_storage) + + data = _import_from_cloud_storage(storage, filename) + + fd, filename = mkstemp(prefix='cvat_') + with open(filename, 'wb+') as f: + f.write(data.getbuffer()) rq_job = queue.enqueue_call( func=rq_func, diff --git a/cvat/apps/iam/permissions.py b/cvat/apps/iam/permissions.py index 04d14abe..fe5a6d15 100644 --- a/cvat/apps/iam/permissions.py +++ b/cvat/apps/iam/permissions.py @@ -63,7 +63,7 @@ class OpenPolicyAgentPermission(metaclass=ABCMeta): 'user': { 'role': self.org_role, }, - } if self.org_id != None else None + } if self.org_id is not None else None } } } @@ -210,7 +210,7 @@ class InvitationPermission(OpenPolicyAgentPermission): 'role': self.role, 'organization': { 'id': self.org_id - } if self.org_id != None else None + } if self.org_id is not None else None } return data @@ -417,7 +417,8 @@ class CloudStoragePermission(OpenPolicyAgentPermission): 'destroy': 'delete', 'content': 'list:content', 'preview': 'view', - 'status': 'view' + 'status': 'view', + 'actions': 'view', }.get(view.action)] def get_resource(self): @@ -427,7 +428,7 @@ class CloudStoragePermission(OpenPolicyAgentPermission): 'owner': { 'id': self.user_id }, 'organization': { 'id': self.org_id - } if self.org_id != None else None, + } if self.org_id is not None else None, 'user': { 'num_resources': Organization.objects.filter( owner=self.user_id).count() @@ -620,9 +621,9 @@ class TaskPermission(OpenPolicyAgentPermission): perm = TaskPermission.create_scope_create(request, org_id) # We don't create a project, just move it. Thus need to decrease # the number of resources. - if obj != None: + if obj is not None: perm.payload['input']['resource']['user']['num_resources'] -= 1 - if obj.project != None: + if obj.project is not None: ValidationError('Cannot change the organization for ' 'a task inside a project') permissions.append(perm) diff --git a/cvat/settings/base.py b/cvat/settings/base.py index ba4587dd..173a0c15 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -352,6 +352,9 @@ os.makedirs(MEDIA_DATA_ROOT, exist_ok=True) CACHE_ROOT = os.path.join(DATA_ROOT, 'cache') os.makedirs(CACHE_ROOT, exist_ok=True) +JOBS_ROOT = os.path.join(DATA_ROOT, 'jobs') +os.makedirs(JOBS_ROOT, exist_ok=True) + TASKS_ROOT = os.path.join(DATA_ROOT, 'tasks') os.makedirs(TASKS_ROOT, exist_ok=True) diff --git a/cvat/settings/testing.py b/cvat/settings/testing.py index b8659aa4..71d9060c 100644 --- a/cvat/settings/testing.py +++ b/cvat/settings/testing.py @@ -22,6 +22,9 @@ os.makedirs(MEDIA_DATA_ROOT, exist_ok=True) CACHE_ROOT = os.path.join(DATA_ROOT, 'cache') os.makedirs(CACHE_ROOT, exist_ok=True) +JOBS_ROOT = os.path.join(DATA_ROOT, 'jobs') +os.makedirs(JOBS_ROOT, exist_ok=True) + TASKS_ROOT = os.path.join(DATA_ROOT, 'tasks') os.makedirs(TASKS_ROOT, exist_ok=True) diff --git a/tests/rest_api/assets/cvat_db/cvat_data.tar.bz2 b/tests/rest_api/assets/cvat_db/cvat_data.tar.bz2 index 38ae1ac135025ad712271f9f0e938578c937e994..2dd5de83d58625b0e58b4499d1a5036f2424a9f5 100644 GIT binary patch literal 46340 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 literal 75638 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 D=sX#c diff --git a/tests/rest_api/assets/cvat_db/data.json b/tests/rest_api/assets/cvat_db/data.json index f09c6d77..d6a70063 100644 --- a/tests/rest_api/assets/cvat_db/data.json +++ b/tests/rest_api/assets/cvat_db/data.json @@ -1413,7 +1413,7 @@ "pk": 1, "fields": { "password": "pbkdf2_sha256$260000$DevmxlmLwciP1P6sZs2Qag$U9DFtjTWx96Sk95qY6UXVcvpdQEP2LcoFBftk5D2RKY=", - "last_login": "2022-06-08T08:32:30.152Z", + "last_login": "2022-06-22T09:20:25.189Z", "is_superuser": true, "username": "admin1", "first_name": "Admin", @@ -2184,6 +2184,14 @@ "model": "gitdata" } }, +{ + "model": "contenttypes.contenttype", + "pk": 49, + "fields": { + "app_label": "engine", + "model": "storage" + } +}, { "model": "sessions.session", "pk": "5x9v6r58e4l9if78anupog0ittsq2w3j", @@ -3688,7 +3696,9 @@ "created_date": "2021-12-14T19:46:37.969Z", "updated_date": "2022-03-05T09:47:49.679Z", "status": "annotation", - "organization": null + "organization": null, + "source_storage": null, + "target_storage": null } }, { @@ -3702,7 +3712,9 @@ "created_date": "2021-12-14T19:52:37.278Z", "updated_date": "2022-03-28T13:04:54.669Z", "status": "annotation", - "organization": 2 + "organization": 2, + "source_storage": null, + "target_storage": null } }, { @@ -3716,7 +3728,9 @@ "created_date": "2022-03-28T13:05:24.659Z", "updated_date": "2022-03-28T13:06:09.283Z", "status": "annotation", - "organization": 2 + "organization": 2, + "source_storage": null, + "target_storage": null } }, { @@ -3730,7 +3744,9 @@ "created_date": "2022-06-08T08:32:45.521Z", "updated_date": "2022-06-08T08:33:20.759Z", "status": "annotation", - "organization": 2 + "organization": 2, + "source_storage": null, + "target_storage": null } }, { @@ -3751,7 +3767,9 @@ "data": 2, "dimension": "2d", "subset": "", - "organization": 1 + "organization": 1, + "source_storage": null, + "target_storage": null } }, { @@ -3772,7 +3790,9 @@ "data": 5, "dimension": "2d", "subset": "", - "organization": null + "organization": null, + "source_storage": null, + "target_storage": null } }, { @@ -3793,7 +3813,9 @@ "data": 6, "dimension": "3d", "subset": "", - "organization": null + "organization": null, + "source_storage": null, + "target_storage": null } }, { @@ -3814,7 +3836,9 @@ "data": 7, "dimension": "2d", "subset": "", - "organization": 2 + "organization": 2, + "source_storage": null, + "target_storage": null } }, { @@ -3835,7 +3859,9 @@ "data": 8, "dimension": "2d", "subset": "", - "organization": null + "organization": null, + "source_storage": null, + "target_storage": null } }, { @@ -3856,7 +3882,9 @@ "data": 9, "dimension": "2d", "subset": "", - "organization": null + "organization": null, + "source_storage": null, + "target_storage": null } }, { @@ -3877,7 +3905,9 @@ "data": 11, "dimension": "2d", "subset": "Train", - "organization": 2 + "organization": 2, + "source_storage": null, + "target_storage": null } }, { @@ -3898,7 +3928,9 @@ "data": null, "dimension": "2d", "subset": "", - "organization": null + "organization": null, + "source_storage": null, + "target_storage": null } }, { @@ -3919,7 +3951,9 @@ "data": 12, "dimension": "2d", "subset": "", - "organization": 2 + "organization": 2, + "source_storage": null, + "target_storage": null } }, { @@ -4724,6 +4758,7 @@ "fields": { "segment": 2, "assignee": 6, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "new" @@ -4735,6 +4770,7 @@ "fields": { "segment": 7, "assignee": 9, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "in progress" @@ -4746,6 +4782,7 @@ "fields": { "segment": 8, "assignee": null, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "new" @@ -4757,6 +4794,7 @@ "fields": { "segment": 9, "assignee": null, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "in progress" @@ -4768,6 +4806,7 @@ "fields": { "segment": 10, "assignee": 1, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "in progress" @@ -4779,6 +4818,7 @@ "fields": { "segment": 11, "assignee": 9, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "in progress" @@ -4790,6 +4830,7 @@ "fields": { "segment": 12, "assignee": null, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "validation", "stage": "validation", "state": "new" @@ -4801,6 +4842,7 @@ "fields": { "segment": 13, "assignee": null, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "validation", "stage": "acceptance", "state": "new" @@ -4812,6 +4854,7 @@ "fields": { "segment": 14, "assignee": null, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "new" @@ -4823,6 +4866,7 @@ "fields": { "segment": 16, "assignee": 7, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "in progress" @@ -4834,6 +4878,7 @@ "fields": { "segment": 17, "assignee": null, + "updated_date": "2022-06-22T09:18:45.296Z", "status": "annotation", "stage": "annotation", "state": "in progress" diff --git a/tests/rest_api/assets/jobs.json b/tests/rest_api/assets/jobs.json index 1282226c..c578c1c2 100644 --- a/tests/rest_api/assets/jobs.json +++ b/tests/rest_api/assets/jobs.json @@ -32,6 +32,7 @@ "status": "annotation", "stop_frame": 4, "task_id": 13, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/17" }, { @@ -69,6 +70,7 @@ "status": "annotation", "stop_frame": 10, "task_id": 11, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/16" }, { @@ -113,6 +115,7 @@ "status": "annotation", "stop_frame": 19, "task_id": 9, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/14" }, { @@ -157,6 +160,7 @@ "status": "validation", "stop_frame": 14, "task_id": 9, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/13" }, { @@ -201,6 +205,7 @@ "status": "validation", "stop_frame": 9, "task_id": 9, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/12" }, { @@ -251,6 +256,7 @@ "status": "annotation", "stop_frame": 4, "task_id": 9, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/11" }, { @@ -288,6 +294,7 @@ "status": "annotation", "stop_frame": 13, "task_id": 8, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/10" }, { @@ -319,6 +326,7 @@ "status": "annotation", "stop_frame": 10, "task_id": 7, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/9" }, { @@ -344,6 +352,7 @@ "status": "annotation", "stop_frame": 0, "task_id": 6, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/8" }, { @@ -375,6 +384,7 @@ "status": "annotation", "stop_frame": 24, "task_id": 5, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/7" }, { @@ -412,6 +422,7 @@ "status": "annotation", "stop_frame": 22, "task_id": 2, + "updated_date": "2022-06-22T09:18:45.296000Z", "url": "http://localhost:8080/api/jobs/2" } ] diff --git a/tests/rest_api/assets/projects.json b/tests/rest_api/assets/projects.json index 3ad5e4b6..6d5a2cbc 100644 --- a/tests/rest_api/assets/projects.json +++ b/tests/rest_api/assets/projects.json @@ -32,7 +32,9 @@ "url": "http://localhost:8080/api/users/1", "username": "admin1" }, + "source_storage": null, "status": "annotation", + "target_storage": null, "task_subsets": [], "tasks": [ 13 @@ -62,7 +64,9 @@ "url": "http://localhost:8080/api/users/3", "username": "user2" }, + "source_storage": null, "status": "annotation", + "target_storage": null, "task_subsets": [], "tasks": [], "updated_date": "2022-03-28T13:06:09.283000Z", @@ -103,7 +107,9 @@ "url": "http://localhost:8080/api/users/10", "username": "business1" }, + "source_storage": null, "status": "annotation", + "target_storage": null, "task_subsets": [ "Train" ], @@ -161,7 +167,9 @@ "url": "http://localhost:8080/api/users/10", "username": "business1" }, + "source_storage": null, "status": "annotation", + "target_storage": null, "task_subsets": [], "tasks": [ 9 diff --git a/tests/rest_api/assets/tasks.json b/tests/rest_api/assets/tasks.json index dd4bd249..a3d21bea 100644 --- a/tests/rest_api/assets/tasks.json +++ b/tests/rest_api/assets/tasks.json @@ -58,8 +58,10 @@ } ], "size": 5, + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2022-06-08T08:33:20.808000Z", "url": "http://localhost:8080/api/tasks/13" }, @@ -91,8 +93,10 @@ "project_id": null, "segment_size": 0, "segments": [], + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2022-03-14T13:24:05.861000Z", "url": "http://localhost:8080/api/tasks/12" }, @@ -163,8 +167,10 @@ } ], "size": 11, + "source_storage": null, "status": "annotation", "subset": "Train", + "target_storage": null, "updated_date": "2022-03-05T10:32:35.568000Z", "url": "http://localhost:8080/api/tasks/11" }, @@ -290,8 +296,10 @@ } ], "size": 20, + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2022-03-05T09:47:49.667000Z", "url": "http://localhost:8080/api/tasks/9" }, @@ -362,8 +370,10 @@ } ], "size": 14, + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2022-03-05T08:52:34.908000Z", "url": "http://localhost:8080/api/tasks/8" }, @@ -428,8 +438,10 @@ } ], "size": 11, + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2022-02-21T10:41:38.540000Z", "url": "http://localhost:8080/api/tasks/7" }, @@ -482,8 +494,10 @@ } ], "size": 1, + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2022-02-16T06:26:54.836000Z", "url": "http://localhost:8080/api/tasks/6" }, @@ -548,8 +562,10 @@ } ], "size": 25, + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2022-02-21T10:40:21.257000Z", "url": "http://localhost:8080/api/tasks/5" }, @@ -620,8 +636,10 @@ } ], "size": 23, + "source_storage": null, "status": "annotation", "subset": "", + "target_storage": null, "updated_date": "2021-12-22T07:14:15.234000Z", "url": "http://localhost:8080/api/tasks/2" } diff --git a/tests/rest_api/assets/users.json b/tests/rest_api/assets/users.json index 9fda64e0..d14cf1b0 100644 --- a/tests/rest_api/assets/users.json +++ b/tests/rest_api/assets/users.json @@ -310,7 +310,7 @@ "is_active": true, "is_staff": true, "is_superuser": true, - "last_login": "2022-06-08T08:32:30.152708Z", + "last_login": "2022-06-22T09:20:25.189000Z", "last_name": "First", "url": "http://localhost:8080/api/users/1", "username": "admin1" diff --git a/tests/rest_api/test_cloud_storages.py b/tests/rest_api/test_cloud_storages.py index 1069c8e6..147959ab 100644 --- a/tests/rest_api/test_cloud_storages.py +++ b/tests/rest_api/test_cloud_storages.py @@ -180,7 +180,7 @@ class TestPatchCloudStorage: ('maintainer', False, True), ('supervisor', False, False), ]) - def test_org_user_update_coud_storage(self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages): + def test_org_user_update_cloud_storage(self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages): cloud_storage = cloud_storages[storage_id] username = cloud_storage['owner']['username'] if is_owner else \ next((u for u in find_users(role=role, org=org_id) if u['id'] != cloud_storage['owner']['id']))['username'] diff --git a/tests/rest_api/test_jobs.py b/tests/rest_api/test_jobs.py index c336f3e1..e807aef2 100644 --- a/tests/rest_api/test_jobs.py +++ b/tests/rest_api/test_jobs.py @@ -44,7 +44,7 @@ class TestGetJobs: response = get_method(user, f'jobs/{jid}', **kwargs) assert response.status_code == HTTPStatus.OK - assert DeepDiff(data, response.json()) == {} + assert DeepDiff(data, response.json(), exclude_paths="root['updated_date']") == {} def _test_get_job_403(self, user, jid, **kwargs): response = get_method(user, f'jobs/{jid}', **kwargs) @@ -83,7 +83,7 @@ class TestListJobs: response = get_method(user, 'jobs', **kwargs, page_size='all') assert response.status_code == HTTPStatus.OK - assert DeepDiff(data, response.json()['results']) == {} + assert DeepDiff(data, response.json()['results'], exclude_paths="root['updated_date']") == {} def _test_list_jobs_403(self, user, **kwargs): response = get_method(user, 'jobs', **kwargs) @@ -123,7 +123,7 @@ class TestGetAnnotations: assert response.status_code == HTTPStatus.OK assert DeepDiff(data, response_data, - exclude_paths="root['version']") == {} + exclude_regex_paths=r"root\['version|updated_date'\]") == {} def _test_get_job_annotations_403(self, user, jid, **kwargs): response = get_method(user, f'jobs/{jid}/annotations', **kwargs) @@ -193,7 +193,7 @@ class TestPatchJobAnnotations: if is_allow: assert response.status_code == HTTPStatus.OK assert DeepDiff(data, response.json(), - exclude_paths="root['version']") == {} + exclude_regex_paths=r"root\['version|updated_date'\]") == {} else: assert response.status_code == HTTPStatus.FORBIDDEN @@ -313,6 +313,7 @@ class TestPatchJob: if is_allow: assert response.status_code == HTTPStatus.OK - assert DeepDiff(expected_data(jid, assignee), response.json()) == {} + assert DeepDiff(expected_data(jid, assignee), response.json(), + exclude_paths="root['updated_date']") == {} else: assert response.status_code == HTTPStatus.FORBIDDEN