From ff1f6cfc5caaa47710f3835dd186826a1309d527 Mon Sep 17 00:00:00 2001 From: Nikita Manovich <40690625+nmanovic@users.noreply.github.com> Date: Mon, 7 Oct 2019 16:36:10 +0300 Subject: [PATCH] Projects (server only, REST API) (#754) * Initial version of projects * Added tests for Projects REST API. * Added information about projects into CHANGELOG --- .codacy.yml | 1 + CHANGELOG.md | 19 + cvat/apps/authentication/auth.py | 72 +++- .../migrations/0022_auto_20191004_0817.py | 38 ++ cvat/apps/engine/models.py | 19 + cvat/apps/engine/serializers.py | 12 +- cvat/apps/engine/tests/test_rest_api.py | 380 +++++++++++++++++- cvat/apps/engine/urls.py | 1 + cvat/apps/engine/views.py | 64 ++- 9 files changed, 591 insertions(+), 15 deletions(-) create mode 100644 cvat/apps/engine/migrations/0022_auto_20191004_0817.py diff --git a/.codacy.yml b/.codacy.yml index 0549c8f5e921..55fc06796848 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,3 +1,4 @@ exclude_paths: - '**/3rdparty/**' - '**/engine/js/cvat-core.min.js' + - CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e560d05d5ba..f3f90e4efc8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0.alpha] - 2020-02-XX +### Added +- Server only support for projects. Extend REST API v1 (/api/v1/projects*). + +### Changed +- + +### Deprecated +- + +### Removed +- + +### Fixed +- + +### Security +- + ## [0.5.0] - 2019-10-12 ### Added - A converter to YOLO format diff --git a/cvat/apps/authentication/auth.py b/cvat/apps/authentication/auth.py index da4613bf3257..2f54558c62af 100644 --- a/cvat/apps/authentication/auth.py +++ b/cvat/apps/authentication/auth.py @@ -63,20 +63,33 @@ def authenticate(self, request): has_annotator_role = rules.is_group_member(str(AUTH_ROLE.ANNOTATOR)) has_observer_role = rules.is_group_member(str(AUTH_ROLE.OBSERVER)) +@rules.predicate +def is_project_owner(db_user, db_project): + # If owner is None (null) the task can be accessed/changed/deleted + # only by admin. At the moment each task has an owner. + return db_project is not None and db_project.owner == db_user + +@rules.predicate +def is_project_assignee(db_user, db_project): + return db_project is not None and db_project.assignee == db_user + +@rules.predicate +def is_project_annotator(db_user, db_project): + db_tasks = list(db_project.tasks.prefetch_related('segment_set').all()) + return any([is_task_annotator(db_user, db_task) for db_task in db_tasks]) + @rules.predicate def is_task_owner(db_user, db_task): # If owner is None (null) the task can be accessed/changed/deleted # only by admin. At the moment each task has an owner. - return db_task.owner == db_user + return db_task.owner == db_user or is_project_owner(db_user, db_task.project) @rules.predicate def is_task_assignee(db_user, db_task): - return db_task.assignee == db_user + return db_task.assignee == db_user or is_project_assignee(db_user, db_task.project) @rules.predicate def is_task_annotator(db_user, db_task): - from functools import reduce - db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all()) return any([is_job_annotator(db_user, db_job) for db_segment in db_segments for db_job in db_segment.job_set.all()]) @@ -101,6 +114,13 @@ def is_job_annotator(db_user, db_job): rules.add_perm('engine.role.annotator', has_annotator_role) rules.add_perm('engine.role.observer', has_observer_role) +rules.add_perm('engine.project.create', has_admin_role | has_user_role) +rules.add_perm('engine.project.access', has_admin_role | has_observer_role | + is_project_owner | is_project_annotator) +rules.add_perm('engine.project.change', has_admin_role | is_project_owner | + is_project_assignee) +rules.add_perm('engine.project.delete', has_admin_role | is_project_owner) + rules.add_perm('engine.task.create', has_admin_role | has_user_role) rules.add_perm('engine.task.access', has_admin_role | has_observer_role | is_task_owner | is_task_annotator) @@ -133,6 +153,26 @@ class ObserverRolePermission(BasePermission): def has_permission(self, request, view): return request.user.has_perm("engine.role.observer") +class ProjectCreatePermission(BasePermission): + # pylint: disable=no-self-use + def has_permission(self, request, view): + return request.user.has_perm("engine.project.create") + +class ProjectAccessPermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + return request.user.has_perm("engine.project.access", obj) + +class ProjectChangePermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + return request.user.has_perm("engine.project.change", obj) + +class ProjectDeletePermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + return request.user.has_perm("engine.project.delete", obj) + class TaskCreatePermission(BasePermission): # pylint: disable=no-self-use def has_permission(self, request, view): @@ -143,7 +183,8 @@ class TaskAccessPermission(BasePermission): def has_object_permission(self, request, view, obj): return request.user.has_perm("engine.task.access", obj) -class TaskGetQuerySetMixin(object): + +class ProjectGetQuerySetMixin(object): def get_queryset(self): queryset = super().get_queryset() user = self.request.user @@ -152,7 +193,26 @@ def get_queryset(self): return queryset else: return queryset.filter(Q(owner=user) | Q(assignee=user) | - Q(segment__job__assignee=user) | Q(assignee=None)).distinct() + Q(task__owner=user) | Q(task__assignee=user) | + Q(task__segment__job__assignee=user)).distinct() + +def filter_task_queryset(queryset, user): + # Don't filter queryset for admin, observer + if has_admin_role(user) or has_observer_role(user): + return queryset + else: + return queryset.filter(Q(owner=user) | Q(assignee=user) | + Q(segment__job__assignee=user) | Q(assignee=None)).distinct() + +class TaskGetQuerySetMixin(object): + def get_queryset(self): + queryset = super().get_queryset() + user = self.request.user + # Don't filter queryset for detail methods + if self.detail: + return queryset + else: + return filter_task_queryset(queryset, user) class TaskChangePermission(BasePermission): # pylint: disable=no-self-use diff --git a/cvat/apps/engine/migrations/0022_auto_20191004_0817.py b/cvat/apps/engine/migrations/0022_auto_20191004_0817.py new file mode 100644 index 000000000000..d6701bf1167d --- /dev/null +++ b/cvat/apps/engine/migrations/0022_auto_20191004_0817.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.3 on 2019-10-04 08:17 + +import cvat.apps.engine.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('engine', '0021_auto_20190826_1827'), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', cvat.apps.engine.models.SafeCharField(max_length=256)), + ('bug_tracker', models.CharField(blank=True, default='', max_length=2000)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('annotation', 'ANNOTATION'), ('validation', 'VALIDATION'), ('completed', 'COMPLETED')], default=cvat.apps.engine.models.StatusChoice('annotation'), max_length=32)), + ('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.AddField( + model_name='task', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tasks', related_query_name='task', to='engine.Project'), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index d41000ade61a..e55dcd24f215 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -33,7 +33,26 @@ def choices(self): def __str__(self): return self.value +class Project(models.Model): + name = SafeCharField(max_length=256) + owner = models.ForeignKey(User, null=True, blank=True, + on_delete=models.SET_NULL, related_name="+") + assignee = models.ForeignKey(User, null=True, blank=True, + on_delete=models.SET_NULL, related_name="+") + bug_tracker = models.CharField(max_length=2000, blank=True, default="") + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now_add=True) + status = models.CharField(max_length=32, choices=StatusChoice.choices(), + default=StatusChoice.ANNOTATION) + + # Extend default permission model + class Meta: + default_permissions = () + class Task(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE, + null=True, blank=True, related_name="tasks", + related_query_name="task") name = SafeCharField(max_length=256) size = models.PositiveIntegerField() mode = models.CharField(max_length=32) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index da90c66f86e9..24a38c1cbf06 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -196,7 +196,8 @@ class Meta: fields = ('url', 'id', 'name', 'size', 'mode', 'owner', 'assignee', 'bug_tracker', 'created_date', 'updated_date', 'overlap', 'segment_size', 'z_order', 'status', 'labels', 'segments', - 'image_quality', 'start_frame', 'stop_frame', 'frame_filter') + 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', + 'project') read_only_fields = ('size', 'mode', 'created_date', 'updated_date', 'status') write_once_fields = ('overlap', 'segment_size', 'image_quality') @@ -245,6 +246,7 @@ def update(self, instance, validated_data): instance.start_frame = validated_data.get('start_frame', instance.start_frame) instance.stop_frame = validated_data.get('stop_frame', instance.stop_frame) instance.frame_filter = validated_data.get('frame_filter', instance.frame_filter) + instance.project = validated_data.get('project', instance.project) labels = validated_data.get('label_set', []) for label in labels: attributes = label.pop('attributespec_set', []) @@ -276,6 +278,14 @@ def update(self, instance, validated_data): instance.save() return instance +class ProjectSerializer(serializers.ModelSerializer): + class Meta: + model = models.Project + fields = ('url', 'id', 'name', 'owner', 'assignee', 'bug_tracker', + 'created_date', 'updated_date', 'status') + read_only_fields = ('created_date', 'updated_date', 'status') + ordering = ['-id'] + class UserSerializer(serializers.ModelSerializer): groups = serializers.SlugRelatedField(many=True, slug_field='name', queryset=Group.objects.all()) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 457a46e8a08b..ad6cac4c79ba 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -12,7 +12,7 @@ from django.conf import settings from django.contrib.auth.models import User, Group from cvat.apps.engine.models import (Task, Segment, Job, StatusChoice, - AttributeType) + AttributeType, Project) from cvat.apps.annotation.models import AnnotationFormat from unittest import mock import io @@ -68,7 +68,7 @@ def create_db_task(data): return db_task -def create_dummy_db_tasks(obj): +def create_dummy_db_tasks(obj, project=None): tasks = [] data = { @@ -79,7 +79,8 @@ def create_dummy_db_tasks(obj): "segment_size": 100, "z_order": False, "image_quality": 75, - "size": 100 + "size": 100, + "project": project } db_task = create_db_task(data) tasks.append(db_task) @@ -91,7 +92,8 @@ def create_dummy_db_tasks(obj): "segment_size": 100, "z_order": True, "image_quality": 50, - "size": 200 + "size": 200, + "project": project } db_task = create_db_task(data) tasks.append(db_task) @@ -104,7 +106,8 @@ def create_dummy_db_tasks(obj): "segment_size": 100, "z_order": False, "image_quality": 75, - "size": 100 + "size": 100, + "project": project } db_task = create_db_task(data) tasks.append(db_task) @@ -116,13 +119,61 @@ def create_dummy_db_tasks(obj): "segment_size": 50, "z_order": False, "image_quality": 95, - "size": 50 + "size": 50, + "project": project } db_task = create_db_task(data) tasks.append(db_task) return tasks +def create_dummy_db_projects(obj): + projects = [] + + data = { + "name": "my empty project", + "owner": obj.owner, + "assignee": obj.assignee, + } + db_project = Project.objects.create(**data) + projects.append(db_project) + + data = { + "name": "my project without assignee", + "owner": obj.user, + } + db_project = Project.objects.create(**data) + create_dummy_db_tasks(obj, db_project) + projects.append(db_project) + + data = { + "name": "my big project", + "owner": obj.owner, + "assignee": obj.assignee, + } + db_project = Project.objects.create(**data) + create_dummy_db_tasks(obj, db_project) + projects.append(db_project) + + data = { + "name": "public project", + } + db_project = Project.objects.create(**data) + create_dummy_db_tasks(obj, db_project) + projects.append(db_project) + + data = { + "name": "super project", + "owner": obj.admin, + "assignee": obj.assignee, + } + db_project = Project.objects.create(**data) + create_dummy_db_tasks(obj, db_project) + projects.append(db_project) + + return projects + + class ForceLogin: def __init__(self, user, client): self.user = user @@ -587,6 +638,323 @@ def test_api_v1_users_id_no_auth_partial(self): response = self._run_api_v1_users_id(None, self.user.id, data) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) +class ProjectListAPITestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + @classmethod + def setUpTestData(cls): + create_db_users(cls) + cls.projects = create_dummy_db_projects(cls) + + def _run_api_v1_projects(self, user, params=""): + with ForceLogin(user, self.client): + response = self.client.get('/api/v1/projects{}'.format(params)) + + return response + + def test_api_v1_projects_admin(self): + response = self._run_api_v1_projects(self.admin) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( + sorted([project.name for project in self.projects]), + sorted([res["name"] for res in response.data["results"]])) + + def test_api_v1_projects_user(self): + response = self._run_api_v1_projects(self.user) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( + sorted([project.name for project in self.projects + if 'my empty project' != project.name]), + sorted([res["name"] for res in response.data["results"]])) + + def test_api_v1_projects_observer(self): + response = self._run_api_v1_projects(self.observer) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( + sorted([project.name for project in self.projects]), + sorted([res["name"] for res in response.data["results"]])) + + def test_api_v1_projects_no_auth(self): + response = self._run_api_v1_projects(None) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + +class ProjectGetAPITestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + @classmethod + def setUpTestData(cls): + create_db_users(cls) + cls.projects = create_dummy_db_projects(cls) + + def _run_api_v1_projects_id(self, pid, user): + with ForceLogin(user, self.client): + response = self.client.get('/api/v1/projects/{}'.format(pid)) + + return response + + def _check_response(self, response, db_project): + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], db_project.name) + owner = db_project.owner.id if db_project.owner else None + self.assertEqual(response.data["owner"], owner) + assignee = db_project.assignee.id if db_project.assignee else None + self.assertEqual(response.data["assignee"], assignee) + self.assertEqual(response.data["status"], db_project.status) + + def _check_api_v1_projects_id(self, user): + for db_project in self.projects: + response = self._run_api_v1_projects_id(db_project.id, user) + if user and user.has_perm("engine.project.access", db_project): + self._check_response(response, db_project) + elif user: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + else: + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_api_v1_projects_id_admin(self): + self._check_api_v1_projects_id(self.admin) + + def test_api_v1_projects_id_user(self): + self._check_api_v1_projects_id(self.user) + + def test_api_v1_projects_id_observer(self): + self._check_api_v1_projects_id(self.observer) + + def test_api_v1_projects_id_no_auth(self): + self._check_api_v1_projects_id(None) + +class ProjectDeleteAPITestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + @classmethod + def setUpTestData(cls): + create_db_users(cls) + cls.projects = create_dummy_db_projects(cls) + + def _run_api_v1_projects_id(self, pid, user): + with ForceLogin(user, self.client): + response = self.client.delete('/api/v1/projects/{}'.format(pid), format="json") + + return response + + def _check_api_v1_projects_id(self, user): + for db_project in self.projects: + response = self._run_api_v1_projects_id(db_project.id, user) + if user and user.has_perm("engine.project.delete", db_project): + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + elif user: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + else: + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_api_v1_projects_id_admin(self): + self._check_api_v1_projects_id(self.admin) + + def test_api_v1_projects_id_user(self): + self._check_api_v1_projects_id(self.user) + + def test_api_v1_projects_id_observer(self): + self._check_api_v1_projects_id(self.observer) + + def test_api_v1_projects_id_no_auth(self): + self._check_api_v1_projects_id(None) + +class ProjectCreateAPITestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + @classmethod + def setUpTestData(cls): + create_db_users(cls) + + def _run_api_v1_projects(self, user, data): + with ForceLogin(user, self.client): + response = self.client.post('/api/v1/projects', data=data, format="json") + + return response + + def _check_response(self, response, user, data): + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["name"], data["name"]) + self.assertEqual(response.data["owner"], data.get("owner", user.id)) + self.assertEqual(response.data["assignee"], data.get("assignee")) + self.assertEqual(response.data["bug_tracker"], data.get("bug_tracker", "")) + self.assertEqual(response.data["status"], StatusChoice.ANNOTATION) + + def _check_api_v1_projects(self, user, data): + response = self._run_api_v1_projects(user, data) + if user and user.has_perm("engine.project.create"): + self._check_response(response, user, data) + elif user: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + else: + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_api_v1_projects_admin(self): + data = { + "name": "new name for the project", + "bug_tracker": "http://example.com" + } + self._check_api_v1_projects(self.admin, data) + + data = { + "owner": self.owner.id, + "assignee": self.assignee.id, + "name": "new name for the project" + } + self._check_api_v1_projects(self.admin, data) + + data = { + "owner": self.admin.id, + "name": "2" + } + self._check_api_v1_projects(self.admin, data) + + + def test_api_v1_projects_user(self): + data = { + "name": "Dummy name", + "bug_tracker": "it is just text" + } + self._check_api_v1_projects(self.user, data) + + data = { + "owner": self.owner.id, + "assignee": self.assignee.id, + "name": "My import project with data" + } + self._check_api_v1_projects(self.user, data) + + + def test_api_v1_projects_observer(self): + data = { + "name": "My Project #1", + "owner": self.owner.id, + "assignee": self.assignee.id + } + self._check_api_v1_projects(self.observer, data) + + def test_api_v1_projects_no_auth(self): + data = { + "name": "My Project #2", + "owner": self.admin.id, + } + self._check_api_v1_projects(None, data) + +class ProjectPartialUpdateAPITestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + @classmethod + def setUpTestData(cls): + create_db_users(cls) + cls.projects = create_dummy_db_projects(cls) + + def _run_api_v1_projects_id(self, pid, user, data): + with ForceLogin(user, self.client): + response = self.client.patch('/api/v1/projects/{}'.format(pid), + data=data, format="json") + + return response + + def _check_response(self, response, db_project, data): + self.assertEqual(response.status_code, status.HTTP_200_OK) + name = data.get("name", db_project.name) + self.assertEqual(response.data["name"], name) + owner = db_project.owner.id if db_project.owner else None + owner = data.get("owner", owner) + self.assertEqual(response.data["owner"], owner) + assignee = db_project.assignee.id if db_project.assignee else None + assignee = data.get("assignee", assignee) + self.assertEqual(response.data["assignee"], assignee) + self.assertEqual(response.data["status"], db_project.status) + + def _check_api_v1_projects_id(self, user, data): + for db_project in self.projects: + response = self._run_api_v1_projects_id(db_project.id, user, data) + if user and user.has_perm("engine.project.change", db_project): + self._check_response(response, db_project, data) + elif user: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + else: + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_api_v1_projects_id_admin(self): + data = { + "name": "new name for the project", + "owner": self.owner.id, + } + self._check_api_v1_projects_id(self.admin, data) + + def test_api_v1_projects_id_user(self): + data = { + "name": "new name for the project", + "owner": self.assignee.id, + } + self._check_api_v1_projects_id(self.user, data) + + def test_api_v1_projects_id_observer(self): + data = { + "name": "new name for the project", + } + self._check_api_v1_projects_id(self.observer, data) + + def test_api_v1_projects_id_no_auth(self): + data = { + "name": "new name for the project", + } + self._check_api_v1_projects_id(None, data) + +class ProjectListOfTasksAPITestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + @classmethod + def setUpTestData(cls): + create_db_users(cls) + cls.projects = create_dummy_db_projects(cls) + + def _run_api_v1_projects_id_tasks(self, user, pid): + with ForceLogin(user, self.client): + response = self.client.get('/api/v1/projects/{}/tasks'.format(pid)) + + return response + + def test_api_v1_projects_id_tasks_admin(self): + project = self.projects[1] + response = self._run_api_v1_projects_id_tasks(self.admin, project.id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( + sorted([task.name for task in project.tasks.all()]), + sorted([res["name"] for res in response.data["results"]])) + + def test_api_v1_projects_id_tasks_user(self): + project = self.projects[1] + response = self._run_api_v1_projects_id_tasks(self.user, project.id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( + sorted([task.name for task in project.tasks.all() + if task.owner in [None, self.user] or + task.assignee in [None, self.user]]), + sorted([res["name"] for res in response.data["results"]])) + + def test_api_v1_projects_id_tasks_observer(self): + project = self.projects[1] + response = self._run_api_v1_projects_id_tasks(self.observer, project.id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( + sorted([task.name for task in project.tasks.all()]), + sorted([res["name"] for res in response.data["results"]])) + + def test_api_v1_projects_id_tasks_no_auth(self): + project = self.projects[1] + response = self._run_api_v1_projects_id_tasks(None, project.id) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + class TaskListAPITestCase(APITestCase): def setUp(self): self.client = APIClient() diff --git a/cvat/apps/engine/urls.py b/cvat/apps/engine/urls.py index 534d72b7b5e4..3abde35ff06c 100644 --- a/cvat/apps/engine/urls.py +++ b/cvat/apps/engine/urls.py @@ -24,6 +24,7 @@ ) router = routers.DefaultRouter(trailing_slash=False) +router.register('projects', views.ProjectViewSet) router.register('tasks', views.TaskViewSet) router.register('jobs', views.JobViewSet) router.register('users', views.UserViewSet) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 08bd89c5b094..2f548c3777e4 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -36,7 +36,8 @@ from cvat.apps.engine.serializers import (TaskSerializer, UserSerializer, ExceptionSerializer, AboutSerializer, JobSerializer, ImageMetaSerializer, RqStatusSerializer, TaskDataSerializer, LabeledDataSerializer, - PluginSerializer, FileInfoSerializer, LogEventSerializer) + PluginSerializer, FileInfoSerializer, LogEventSerializer, + ProjectSerializer) from cvat.apps.annotation.serializers import AnnotationFileSerializer from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist @@ -158,7 +159,65 @@ def formats(request): data = get_annotation_formats() return Response(data) +class ProjectFilter(filters.FilterSet): + name = filters.CharFilter(field_name="name", lookup_expr="icontains") + owner = filters.CharFilter(field_name="owner__username", lookup_expr="icontains") + status = filters.CharFilter(field_name="status", lookup_expr="icontains") + assignee = filters.CharFilter(field_name="assignee__username", lookup_expr="icontains") + + class Meta: + model = models.Project + fields = ("id", "name", "owner", "status", "assignee") + +class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet): + queryset = models.Project.objects.all().order_by('-id') + serializer_class = ProjectSerializer + search_fields = ("name", "owner__username", "assignee__username", "status") + filterset_class = ProjectFilter + ordering_fields = ("id", "name", "owner", "status", "assignee") + http_method_names = ['get', 'post', 'head', 'patch', 'delete'] + + def get_permissions(self): + http_method = self.request.method + permissions = [IsAuthenticated] + + if http_method in SAFE_METHODS: + permissions.append(auth.ProjectAccessPermission) + elif http_method in ["POST"]: + permissions.append(auth.ProjectCreatePermission) + elif http_method in ["PATCH"]: + permissions.append(auth.ProjectChangePermission) + elif http_method in ["DELETE"]: + permissions.append(auth.ProjectDeletePermission) + else: + permissions.append(auth.AdminRolePermission) + + return [perm() for perm in permissions] + + def perform_create(self, serializer): + if self.request.data.get('owner', None): + serializer.save() + else: + serializer.save(owner=self.request.user) + + @action(detail=True, methods=['GET'], serializer_class=TaskSerializer) + def tasks(self, request, pk): + self.get_object() # force to call check_object_permissions + queryset = Task.objects.filter(project_id=pk).order_by('-id') + queryset = auth.filter_task_queryset(queryset, request.user) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True, + context={"request": request}) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True, + context={"request": request}) + return Response(serializer.data) + class TaskFilter(filters.FilterSet): + project = filters.CharFilter(field_name="project__name", lookup_expr="icontains") name = filters.CharFilter(field_name="name", lookup_expr="icontains") owner = filters.CharFilter(field_name="owner__username", lookup_expr="icontains") mode = filters.CharFilter(field_name="mode", lookup_expr="icontains") @@ -167,7 +226,8 @@ class TaskFilter(filters.FilterSet): class Meta: model = Task - fields = ("id", "name", "owner", "mode", "status", "assignee") + fields = ("id", "project_id", "project", "name", "owner", "mode", "status", + "assignee") class TaskViewSet(auth.TaskGetQuerySetMixin, viewsets.ModelViewSet): queryset = Task.objects.all().prefetch_related(