diff --git a/.gitignore b/.gitignore index c85c274976..b51d3e8b40 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,4 @@ src/ralph/var/ debian/ralph-core* .idea - !src/ralph/lib diff --git a/src/ralph/api/__init__.py b/src/ralph/api/__init__.py index e65573ad8b..a797c1cadc 100644 --- a/src/ralph/api/__init__.py +++ b/src/ralph/api/__init__.py @@ -1,9 +1,10 @@ from ralph.api.serializers import RalphAPISerializer -from ralph.api.viewsets import RalphAPIViewSet +from ralph.api.viewsets import RalphAPIViewSet, RalphReadOnlyAPIViewSet from ralph.api.routers import router __all__ = [ 'RalphAPISerializer', 'RalphAPIViewSet', + 'RalphReadOnlyAPIViewSet', 'router', ] diff --git a/src/ralph/api/fields.py b/src/ralph/api/fields.py index 3442fd006e..da3ad7ac34 100644 --- a/src/ralph/api/fields.py +++ b/src/ralph/api/fields.py @@ -1,6 +1,6 @@ from collections import OrderedDict -from rest_framework.fields import ChoiceField, Field +from rest_framework.fields import ChoiceField, Field, MultipleChoiceField class StrField(Field): @@ -56,3 +56,19 @@ def to_internal_value(self, data): except KeyError: pass return super(ReversedChoiceField, self).to_internal_value(data) + + +class ModelMultipleChoiceField(MultipleChoiceField): + """ + Multiple Model Choices Field for Django Rest Framework + + Changes list of integer data to Django Model queryset + """ + def __init__(self, *args, **kwargs): + self.model = kwargs['choices'].queryset.model + super().__init__(*args, **kwargs) + + def to_internal_value(self, data): + if data: + return self.model.objects.filter(pk__in=data) + return self.model.objects.none() diff --git a/src/ralph/api/viewsets.py b/src/ralph/api/viewsets.py index f2179edd9d..eb9ea800ad 100644 --- a/src/ralph/api/viewsets.py +++ b/src/ralph/api/viewsets.py @@ -137,3 +137,11 @@ class RalphAPIViewSet( metaclass=RalphAPIViewSetMetaclass ): pass + + +class RalphReadOnlyAPIViewSet( + RalphAPIViewSetMixin, + viewsets.ReadOnlyModelViewSet, + metaclass=RalphAPIViewSetMetaclass +): + pass diff --git a/src/ralph/lib/external_services/models.py b/src/ralph/lib/external_services/models.py index e1cb2580d6..4430820c0d 100644 --- a/src/ralph/lib/external_services/models.py +++ b/src/ralph/lib/external_services/models.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- import logging import uuid +from datetime import date +from dateutil.parser import parse from dj.choices import Choices from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models.query import QuerySet from django.utils.translation import ugettext_lazy as _ from django_extensions.db.fields.json import JSONField @@ -137,8 +140,21 @@ def dump_obj_to_jsonable(cls, obj): Dump obj to JSON-acceptable format """ result = obj - if isinstance(obj, (list, tuple)): + if isinstance(obj, (list, tuple, set)): result = [cls.dump_obj_to_jsonable(p) for p in obj] + elif isinstance(obj, QuerySet): + result = { + '__django_queryset': True, + 'value': [i.pk for i in obj], + 'content_type_id': ContentType.objects.get_for_model( + obj.model + ).pk + } + elif isinstance(obj, date): + result = { + '__date': True, + 'value': str(obj) + } elif isinstance(obj, dict): result = {} for k, v in obj.items(): @@ -161,7 +177,14 @@ def _restore_django_models(cls, obj): if isinstance(obj, (list, tuple)): result = [cls._restore_django_models(p) for p in obj] elif isinstance(obj, dict): - if obj.get('__django_model') is True: + if obj.get('__date') is True: + result = parse(obj.get('value')).date() + elif obj.get('__django_queryset') is True: + ct = ContentType.objects.get_for_id(obj['content_type_id']) + result = ct.model_class().objects.filter( + pk__in=obj.get('value') + ) + elif obj.get('__django_model') is True: ct = ContentType.objects.get_for_id(obj['content_type_id']) result = ct.get_object_for_this_type(pk=obj['object_pk']) else: diff --git a/src/ralph/lib/transitions/api/routers.py b/src/ralph/lib/transitions/api/routers.py new file mode 100644 index 0000000000..6416c8c25a --- /dev/null +++ b/src/ralph/lib/transitions/api/routers.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from django.conf.urls import url + +from ralph.api import router +from ralph.lib.transitions.api.views import ( + TransitionActionViewSet, + TransitionJobViewSet, + TransitionModelViewSet, + TransitionView, + TransitionViewSet +) + +router.register(r'transitions', TransitionViewSet) +router.register(r'transitions-action', TransitionActionViewSet) +router.register(r'transitions-model', TransitionModelViewSet) +router.register(r'transitions-job', TransitionJobViewSet) + + +urlpatterns = [url( + r'^transition/(?P[0-9]+)/(?P\w+)$', + TransitionView.as_view(), + name='transition-view' +)] diff --git a/src/ralph/lib/transitions/api/serializers.py b/src/ralph/lib/transitions/api/serializers.py new file mode 100644 index 0000000000..3f22d52d8a --- /dev/null +++ b/src/ralph/lib/transitions/api/serializers.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from rest_framework import serializers + +from ralph.api import RalphAPISerializer +from ralph.lib.transitions.models import ( + Action, + Transition, + TransitionJob, + TransitionModel +) + + +class TransitionModelSerializer(RalphAPISerializer): + + model = serializers.CharField(source='content_type.model') + + class Meta: + model = TransitionModel + exclude = ('content_type',) + + +class TransitionActionSerializer(RalphAPISerializer): + + class Meta: + model = Action + exclude = ('content_type',) + + +class TransitionSerializer(RalphAPISerializer): + + class Meta: + model = Transition + + +class TransitionJobSerializer(RalphAPISerializer): + + class Meta: + model = TransitionJob + exclude = ('content_type',) diff --git a/src/ralph/lib/transitions/api/views.py b/src/ralph/lib/transitions/api/views.py new file mode 100644 index 0000000000..d91de09f92 --- /dev/null +++ b/src/ralph/lib/transitions/api/views.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +from django import forms +from django.core.urlresolvers import reverse +from rest_framework import serializers +from rest_framework.response import Response +from rest_framework.views import APIView + +from ralph.api import RalphReadOnlyAPIViewSet +from ralph.api.fields import ModelMultipleChoiceField +from ralph.lib.transitions.api.serializers import ( + TransitionActionSerializer, + TransitionJobSerializer, + TransitionModelSerializer, + TransitionSerializer +) +from ralph.lib.transitions.models import ( + Action, + run_transition, + Transition, + TransitionJob, + TransitionModel +) +from ralph.lib.transitions.views import collect_actions + +FIELD_MAP = { + forms.CharField: (serializers.CharField, [ + 'max_length', 'initial', 'required' + ]), + forms.BooleanField: (serializers.BooleanField, ['initial', 'required']), + forms.URLField: (serializers.URLField, ['initial', 'required']), + forms.IntegerField: (serializers.IntegerField, ['initial', 'required']), + forms.DecimalField: (serializers.DecimalField, ['initial', 'required']), + forms.DateField: (serializers.DateField, ['initial', 'required']), + forms.DateTimeField: (serializers.DateTimeField, ['initial', 'required']), + forms.TimeField: (serializers.TimeField, ['initial', 'required']), + forms.ModelMultipleChoiceField: (ModelMultipleChoiceField, [ + 'initial', 'required', 'choices' + ]), + forms.ModelChoiceField: (serializers.ChoiceField, [ + 'initial', 'required', 'choices' + ]), + forms.ChoiceField: (serializers.ChoiceField, [ + 'initial', 'required', 'choices' + ]), +} + + +class TransitionJobViewSet(RalphReadOnlyAPIViewSet): + queryset = TransitionJob.objects.all() + serializer_class = TransitionJobSerializer + + +class TransitionModelViewSet(RalphReadOnlyAPIViewSet): + queryset = TransitionModel.objects.all() + serializer_class = TransitionModelSerializer + + +class TransitionActionViewSet(RalphReadOnlyAPIViewSet): + queryset = Action.objects.all() + serializer_class = TransitionActionSerializer + + +class TransitionViewSet(RalphReadOnlyAPIViewSet): + queryset = Transition.objects.all() + serializer_class = TransitionSerializer + prefetch_related = ['actions'] + + +class TransitionView(APIView): + + def dispatch(self, request, transition_pk, obj_pk, *args, **kwargs): + self.transition = Transition.objects.get( + pk=transition_pk + ) + self.obj = self.transition.model.content_type.get_object_for_this_type( + pk=obj_pk + ) + self.actions, self.return_attachment = collect_actions( + self.obj, self.transition + ) + return super().dispatch(request, *args, **kwargs) + + def get_fields(self): + fields = {} + fields_name_map = {} + for action in self.actions: + action_fields = getattr(action, 'form_fields', {}) + for name, options in action_fields.items(): + field_class, field_attr = FIELD_MAP.get( + options['field'].__class__, None + ) + attrs = { + name: getattr( + options['field'], name, None + ) for name in field_attr + } + fields_name_map[name] = '{}__{}'.format(action.__name__, name) + fields[name] = field_class(**attrs) + return fields, fields_name_map + + def get_serializer_class(self): + class_name = 'TransitionSerializer{}'.format( + self.obj.__class__.__name__ + ) + class_attrs, _ = self.get_fields() + serializer_class = type( + class_name, (serializers.Serializer,), class_attrs + ) + + return serializer_class + + def get_serializer(self, only_class=False): + return self.get_serializer_class()() + + def add_function_name_to_data(self, data): + result = {} + _, fields_name_map = self.get_fields() + for k, v in data.items(): + result[fields_name_map.get(k)] = v + return result + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer_class()(data=request.data) + result = {'status': False} + if serializer.is_valid(): + data = self.add_function_name_to_data(serializer.validated_data) + transition_result = run_transition( + [self.obj], + self.transition, + self.transition.model.field_name, + data, + request=request + ) + if self.transition.is_async: + result['job_ids'] = [ + reverse('transitionjob-detail', args=(i,)) + for i in transition_result + ] + result['status'] = True if transition_result else False + else: + result['status'] = transition_result[0] + else: + result['errors'] = serializer.errors + + return Response(result) diff --git a/src/ralph/lib/transitions/async.py b/src/ralph/lib/transitions/async.py index 3bbe1c3f3d..3eb186ac7c 100644 --- a/src/ralph/lib/transitions/async.py +++ b/src/ralph/lib/transitions/async.py @@ -144,7 +144,7 @@ def _perform_async_transition(transition_job): # save obj and history _post_transition_instance_processing( - obj, transition, transition_job.params, func_history_kwargs, + obj, transition, transition_job.params['data'], func_history_kwargs, user=transition_job.user, attachment=attachment, ) transition_job.success() diff --git a/src/ralph/lib/transitions/models.py b/src/ralph/lib/transitions/models.py index 3d77a63898..3aee690c54 100644 --- a/src/ralph/lib/transitions/models.py +++ b/src/ralph/lib/transitions/models.py @@ -162,7 +162,7 @@ def _check_instances_for_transition(instances, transition): Args: instances: Objects to checks. - transition: The transition object or a string. + transition: The transition object. Raises: TransitionNotAllowedError: An error ocurred when one or more of diff --git a/src/ralph/lib/transitions/tests/test_actions.py b/src/ralph/lib/transitions/tests/test_actions.py new file mode 100644 index 0000000000..4ea219b1c3 --- /dev/null +++ b/src/ralph/lib/transitions/tests/test_actions.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +from dj.choices import Country +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +from django.test import Client +from rest_framework.test import APIClient + +from ralph.accounts.tests.factories import UserFactory +from ralph.assets.models.base import BaseObject +from ralph.back_office.models import BackOfficeAsset, BackOfficeAssetStatus +from ralph.back_office.tests.factories import ( + BackOfficeAssetFactory, + OfficeInfrastructureFactory, + WarehouseFactory +) +from ralph.lib.transitions.models import TransitionsHistory +from ralph.lib.transitions.tests import TransitionTestCase +from ralph.licences.tests.factories import LicenceFactory + + +class TransitionActionTest(TransitionTestCase): + def setUp(self): + super().setUp() + self.warehouse_1 = WarehouseFactory(name='api_test') + self.user = UserFactory() + self.officeinfrastructure = OfficeInfrastructureFactory() + self.bo = BackOfficeAssetFactory( + status=BackOfficeAssetStatus.new, + user=UserFactory(), + owner=UserFactory() + ) + self.licence_1 = LicenceFactory() + self.licence_2 = LicenceFactory() + actions = [ + 'assign_user', 'assign_licence', 'assign_owner', + 'assign_loan_end_date', 'assign_warehouse', + 'assign_office_infrastructure', 'add_remarks', 'assign_task_url', + 'change_hostname' + ] + self.transition_1 = self._create_transition( + BackOfficeAsset, 'BackOffice', actions, 'status', + [BackOfficeAssetStatus.new.id], + BackOfficeAssetStatus.in_progress.id + )[1] + self.transition_2 = self._create_transition( + BackOfficeAsset, 'BackOffice_Async', actions, 'status', + [BackOfficeAssetStatus.new.id], + BackOfficeAssetStatus.in_progress.id, + run_asynchronously=True + )[1] + self.superuser = get_user_model().objects.create_superuser( + 'test', 'test@test.test', 'test' + ) + self.api_client = APIClient() + self.api_client.login(username='test', password='test') + + self.client = Client() + self.client.login(username='test', password='test') + + def prepare_gui_data(self): + return { + 'add_remarks__remarks': 'remarks', + 'assign_owner__owner': self.user.id, + 'assign_loan_end_date__loan_end_date': '2016-04-01', + 'assign_task_url__task_url': 'http://test.allegro.pl', + 'assign_user__user': self.user.id, + 'assign_office_infrastructure__office_infrastructure': + self.officeinfrastructure.id, + 'assign_warehouse__warehouse': self.warehouse_1.id, + 'change_hostname__country': Country.pl.id, + 'assign_licence__licences': [self.licence_1.id, self.licence_2.id] + } + + def prepare_api_data(self): + return { + 'remarks': 'remarks', + 'owner': self.user.id, + 'loan_end_date': '2016-04-01', + 'task_url': 'http://test.allegro.pl', + 'user': self.user.id, + 'office_infrastructure': self.officeinfrastructure.id, + 'warehouse': self.warehouse_1.id, + 'country': Country.pl.id, + 'licences': [self.licence_1.id, self.licence_2.id] + } + + def get_transition_history(self, object_id): + return TransitionsHistory.objects.filter( + content_type=ContentType.objects.get_for_model(BaseObject), + object_id=object_id + ).last() + + def test_sync_api(self): + request = self.api_client.post( + reverse( + 'transition-view', + args=(self.transition_1.id, self.bo.pk) + ), + self.prepare_api_data() + ) + self.assertEqual(request.data['status'], True) + + bo = BackOfficeAsset.objects.get(pk=self.bo) + self.assertEqual(bo.owner_id, self.user.id) + self.assertEqual(bo.warehouse_id, self.warehouse_1.id) + history = self.get_transition_history(bo.pk) + self.assertIn(self.user.username, history.kwargs['user']) + + def test_sync_api_validation_error(self): + data = self.prepare_api_data() + data['user'] = '' + request = self.api_client.post( + reverse( + 'transition-view', + args=(self.transition_1.id, self.bo.pk) + ), + data + ) + + self.assertEqual(request.data['status'], False) + self.assertEqual( + request.data['errors']['user'], + ['This field is required.'] + ) + + def test_sync_gui(self): + request = self.client.post( + reverse( + 'admin:back_office_backofficeasset_transition', + args=(self.bo.id, self.transition_1.id) + ), + self.prepare_gui_data(), + follow=True + ) + self.assertTrue( + request.redirect_chain[0][0], + reverse( + 'admin:back_office_backofficeasset_change', + args=(self.bo.id,) + ) + ) + bo = BackOfficeAsset.objects.get(pk=self.bo) + self.assertEqual(bo.owner_id, self.user.id) + self.assertEqual(bo.warehouse_id, self.warehouse_1.id) + history = self.get_transition_history(bo.pk) + self.assertIn(self.user.username, history.kwargs['user']) + + def test_sync_gui_validation_error(self): + data = self.prepare_gui_data() + data['assign_warehouse__warehouse'] = '' + request = self.client.post( + reverse( + 'admin:back_office_backofficeasset_transition', + args=(self.bo.id, self.transition_1.id) + ), + data, + follow=True + ) + self.assertEqual( + request.context['form'].errors['assign_warehouse__warehouse'], + ['This field is required.'] + ) + + def test_async_api(self): + request = self.api_client.post( + reverse( + 'transition-view', + args=(self.transition_2.id, self.bo.pk) + ), + self.prepare_api_data() + ) + self.assertEqual(request.data['status'], True) + bo = BackOfficeAsset.objects.get(pk=self.bo) + self.assertEqual(bo.owner_id, self.user.id) + self.assertEqual(bo.warehouse_id, self.warehouse_1.id) + history = self.get_transition_history(bo.pk) + self.assertIn(self.user.username, history.kwargs['user']) + + def test_async_gui(self): + request = self.client.post( + reverse( + 'admin:back_office_backofficeasset_transition', + args=(self.bo.id, self.transition_2.id) + ), + self.prepare_gui_data(), + follow=True + ) + self.assertTrue( + request.redirect_chain[0][0], + reverse( + 'admin:back_office_backofficeasset_change', + args=(self.bo.id,) + ) + ) + bo = BackOfficeAsset.objects.get(pk=self.bo) + self.assertEqual(bo.owner_id, self.user.id) + self.assertEqual(bo.warehouse_id, self.warehouse_1.id) + history = self.get_transition_history(bo.pk) + self.assertIn(self.user.username, history.kwargs['user']) + + def test_api_options(self): + request = self.api_client.options( + reverse( + 'transition-view', + args=(self.transition_1.id, self.bo.pk) + ) + ) + self.assertEqual(request.status_code, 200) + self.assertEqual( + request.data['actions']['POST'][ + 'remarks' + ]['type'], 'string' + ) + self.assertEqual( + request.data['actions']['POST'][ + 'loan_end_date' + ]['type'], 'date' + ) + self.assertEqual( + request.data['actions']['POST'][ + 'country' + ]['type'], 'choice' + ) diff --git a/src/ralph/lib/transitions/views.py b/src/ralph/lib/transitions/views.py index b563dc0a82..60ea7b94ff 100644 --- a/src/ralph/lib/transitions/views.py +++ b/src/ralph/lib/transitions/views.py @@ -29,6 +29,16 @@ ) +def collect_actions(obj, transition): + names = transition.actions.values_list('name', flat=True).all() + actions = [getattr(obj, name) for name in names] + return_attachment = [ + getattr(action, 'return_attachment', False) + for action in actions + ] + return actions, any(return_attachment) + + class TransitionViewMixin(object): template_name = 'transitions/run_transition.html' @@ -39,15 +49,6 @@ def _objects_are_valid(self): return False, e return True, None - def collect_actions(self, transition): - names = transition.actions.values_list('name', flat=True).all() - actions = [getattr(self.obj, name) for name in names] - return_attachment = [ - getattr(action, 'return_attachment', False) - for action in actions - ] - return actions, any(return_attachment) - @property def form_fields_from_actions(self): fields = {} @@ -96,7 +97,9 @@ def dispatch(self, request, *args, **kwargs): ) ): return HttpResponseForbidden() - self.actions, self.return_attachment = self.collect_actions(self.transition) # noqa + self.actions, self.return_attachment = collect_actions( + self.obj, self.transition + ) if not len(self.form_fields_from_actions): return self.run_and_redirect(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs) diff --git a/src/ralph/urls/base.py b/src/ralph/urls/base.py index 872a91e94a..b42a5fa978 100644 --- a/src/ralph/urls/base.py +++ b/src/ralph/urls/base.py @@ -17,6 +17,7 @@ 'ralph.supports.api', 'ralph.security.api', 'ralph.virtual.api', + 'ralph.lib.transitions.api.routers' ])) # include router urls # because we're using single router instance and urls are cached inside this