diff --git a/datahub/event/serializers.py b/datahub/event/serializers.py index 31854d2cc..4438acfb0 100644 --- a/datahub/event/serializers.py +++ b/datahub/event/serializers.py @@ -13,13 +13,11 @@ class EventSerializer(serializers.ModelSerializer): default_error_messages = { 'lead_team_not_in_teams': ugettext_lazy('Lead team must be in teams array.'), - 'end_date_without_start_date': ugettext_lazy('Cannot have an end date without a start ' - 'date.'), 'end_date_before_start_date': ugettext_lazy('End date cannot be before start date.'), 'uk_region_non_uk_country': ugettext_lazy('Cannot specify a UK region for a non-UK ' 'country.') } - + end_date = serializers.DateField() event_type = NestedRelatedField('event.EventType') location_type = NestedRelatedField('event.LocationType', required=False, allow_null=True) organiser = NestedAdviserField(required=False, allow_null=True) @@ -31,6 +29,7 @@ class EventSerializer(serializers.ModelSerializer): 'event.Programme', many=True, required=False, allow_empty=True ) service = NestedRelatedField('metadata.Service') + start_date = serializers.DateField() def validate(self, data): """Performs cross-field validation.""" @@ -63,14 +62,12 @@ def _validate_lead_team(self, combiner): def _validate_dates(self, combiner): errors = {} + start_date = combiner.get_value('start_date') end_date = combiner.get_value('end_date') - if end_date: - if not start_date: - errors['end_date'] = self.error_messages['end_date_without_start_date'] - elif end_date < start_date: - errors['end_date'] = self.error_messages['end_date_before_start_date'] + if start_date and end_date and end_date < start_date: + errors['end_date'] = self.error_messages['end_date_before_start_date'] return errors diff --git a/datahub/event/test/factories.py b/datahub/event/test/factories.py index 2bcc966dd..ee32a8ab6 100644 --- a/datahub/event/test/factories.py +++ b/datahub/event/test/factories.py @@ -17,6 +17,7 @@ class EventFactory(factory.django.DjangoModelFactory): name = factory.Faker('text') event_type_id = EventType.seminar.value.id start_date = factory.Faker('date') + end_date = factory.LazyAttribute(lambda event: event.start_date) location_type_id = LocationType.hq.value.id address_1 = factory.Faker('text') address_2 = factory.Faker('text') diff --git a/datahub/event/test/test_views.py b/datahub/event/test/test_views.py index d702c6525..dbb792b8b 100644 --- a/datahub/event/test/test_views.py +++ b/datahub/event/test/test_views.py @@ -100,6 +100,7 @@ class TestCreateEventView(APITestMixin): def test_create_minimal_success(self): """Tests successfully creating an event with only the required fields.""" url = reverse('api-v3:event:collection') + request_data = { 'name': 'Grand exhibition', 'event_type': EventType.seminar.value.id, @@ -107,6 +108,8 @@ def test_create_minimal_success(self): 'address_town': 'New York', 'address_country': Country.united_states.value.id, 'service': Service.trade_enquiry.value.id, + 'start_date': '2010-09-12', + 'end_date': '2010-09-12', } response = self.api_client.post(url, format='json', data=request_data) @@ -119,8 +122,8 @@ def test_create_minimal_success(self): 'id': EventType.seminar.value.id, 'name': EventType.seminar.value.name, }, - 'start_date': None, - 'end_date': None, + 'start_date': '2010-09-12', + 'end_date': '2010-09-12', 'location_type': None, 'notes': '', 'address_1': 'Grand Court Exhibition Centre', @@ -230,6 +233,7 @@ def test_create_lead_team_not_in_teams(self): url = reverse('api-v3:event:collection') request_data = { 'name': 'Grand exhibition', + 'end_date': '2010-09-12', 'event_type': EventType.seminar.value.id, 'address_1': 'Grand Court Exhibition Centre', 'address_town': 'London', @@ -238,6 +242,7 @@ def test_create_lead_team_not_in_teams(self): 'lead_team': Team.crm.value.id, 'teams': [Team.healthcare_uk.value.id], 'service': Service.trade_enquiry.value.id, + 'start_date': '2010-09-12', } response = self.api_client.post(url, format='json', data=request_data) @@ -252,11 +257,13 @@ def test_create_uk_no_uk_region(self): url = reverse('api-v3:event:collection') request_data = { 'name': 'Grand exhibition', + 'end_date': '2010-09-12', 'event_type': EventType.seminar.value.id, 'address_1': 'Grand Court Exhibition Centre', 'address_town': 'London', 'address_country': Country.united_kingdom.value.id, 'service': Service.trade_enquiry.value.id, + 'start_date': '2010-09-12', } response = self.api_client.post(url, format='json', data=request_data) @@ -272,11 +279,13 @@ def test_create_non_uk_with_uk_region(self): request_data = { 'name': 'Grand exhibition', 'event_type': EventType.seminar.value.id, + 'end_date': '2010-09-12', 'address_1': 'Grand Court Exhibition Centre', 'address_town': 'London', 'address_country': Country.united_states.value.id, 'uk_region': UKRegion.east_of_england.value.id, 'service': Service.trade_enquiry.value.id, + 'start_date': '2010-09-12', } response = self.api_client.post(url, format='json', data=request_data) @@ -304,7 +313,7 @@ def test_create_end_date_without_start_date(self): assert response.status_code == status.HTTP_400_BAD_REQUEST response_data = response.json() assert response_data == { - 'end_date': ['Cannot have an end date without a start date.'] + 'start_date': ['This field is required.'] } def test_create_end_date_before_start_date(self): @@ -341,9 +350,11 @@ def test_create_omitted_failure(self): 'address_1': ['This field is required.'], 'address_country': ['This field is required.'], 'address_town': ['This field is required.'], + 'end_date': ['This field is required.'], 'event_type': ['This field is required.'], 'name': ['This field is required.'], 'service': ['This field is required.'], + 'start_date': ['This field is required.'], } def test_create_blank_failure(self): @@ -353,9 +364,11 @@ def test_create_blank_failure(self): 'address_1': '', 'address_country': None, 'address_town': '', + 'end_date': None, 'event_type': None, 'name': '', 'service': None, + 'start_date': None, } response = self.api_client.post(url, format='json', data=request_data) @@ -365,9 +378,11 @@ def test_create_blank_failure(self): 'address_1': ['This field may not be blank.'], 'address_country': ['This field may not be null.'], 'address_town': ['This field may not be blank.'], + 'end_date': ['This field may not be null.'], 'event_type': ['This field may not be null.'], 'name': ['This field may not be blank.'], 'service': ['This field may not be null.'], + 'start_date': ['This field may not be null.'], } @@ -470,6 +485,20 @@ def test_patch_lead_team_success(self): response_data = _get_canonical_response_data(response) assert response_data['lead_team']['id'] == Team.healthcare_uk.value.id + def test_patch_null_end_date_failure(self): + """Test updating an event's end date with null.""" + event = EventFactory(end_date=None) + url = reverse('api-v3:event:item', kwargs={'pk': event.pk}) + + request_data = { + 'end_date': None, + } + response = self.api_client.patch(url, request_data, format='json') + + response_data = response.json() + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response_data['end_date'] == ['This field may not be null.'] + def test_patch_lead_team_failure(self): """Test updating an event's lead team to an invalid team.""" event = EventFactory() diff --git a/datahub/omis/order/migrations/0002_order_paid_on.py b/datahub/omis/order/migrations/0002_order_paid_on.py new file mode 100644 index 000000000..011390b14 --- /dev/null +++ b/datahub/omis/order/migrations/0002_order_paid_on.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-27 16:09 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0001_squashed_0030_cancellation'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='paid_on', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/datahub/omis/order/models.py b/datahub/omis/order/models.py index e464827fc..20750a748 100644 --- a/datahub/omis/order/models.py +++ b/datahub/omis/order/models.py @@ -206,6 +206,8 @@ class Order(BaseModel): related_name='+' ) + paid_on = models.DateTimeField(null=True, blank=True) + completed_on = models.DateTimeField(null=True, blank=True) completed_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -425,6 +427,33 @@ def mark_as_paid(self, by, payments_data): Payment.objects.create_from_order(self, by, data) self.status = OrderStatus.paid + self.paid_on = max(item['received_on'] for item in payments_data) + self.save() + + @transaction.atomic + def complete(self, by): + """ + Complete an order. + + :param by: the adviser who marked the order as complete + """ + for order_validator in [ + validators.OrderInStatusValidator( + allowed_statuses=( + OrderStatus.paid, + ) + ) + ]: + order_validator.set_instance(self) + order_validator() + + for complete_validator in [validators.CompletableOrderValidator()]: + complete_validator.set_order(self) + complete_validator() + + self.status = OrderStatus.complete + self.completed_on = now() + self.completed_by = by self.save() diff --git a/datahub/omis/order/serializers.py b/datahub/omis/order/serializers.py index 457ad082c..904f95a76 100644 --- a/datahub/omis/order/serializers.py +++ b/datahub/omis/order/serializers.py @@ -89,6 +89,7 @@ class Meta: 'billing_address_postcode', 'billing_address_country', 'archived_documents_url_path', + 'paid_on', 'completed_by', 'completed_on', 'cancelled_by', @@ -115,6 +116,7 @@ class Meta: 'vat_cost', 'total_cost', 'archived_documents_url_path', + 'paid_on', 'completed_by', 'completed_on', 'cancelled_by', @@ -196,6 +198,13 @@ def validate(self, data): data = self._reset_vat_fields_if_necessary(data) return data + def complete(self): + """Mark an order as complete.""" + self.instance.complete( + by=self.context['current_user'] + ) + return self.instance + class PublicOrderSerializer(serializers.ModelSerializer): """DRF serializer for public facing API.""" @@ -230,6 +239,7 @@ class Meta: 'billing_address_county', 'billing_address_postcode', 'billing_address_country', + 'paid_on', 'completed_on', ) read_only_fields = fields diff --git a/datahub/omis/order/test/factories.py b/datahub/omis/order/test/factories.py index 223ff9601..dcada44dc 100644 --- a/datahub/omis/order/test/factories.py +++ b/datahub/omis/order/test/factories.py @@ -118,6 +118,7 @@ class OrderCancelledFactory(OrderWithAcceptedQuoteFactory): class OrderPaidFactory(OrderWithAcceptedQuoteFactory): """Factory for orders marked as paid.""" + paid_on = factory.Faker('date_time') status = OrderStatus.paid @@ -144,6 +145,12 @@ class Meta: model = 'order.OrderAssignee' +class OrderAssigneeCompleteFactory(OrderAssigneeFactory): + """Order Assignee factory with actual time set.""" + + actual_time = factory.Faker('random_int', min=10, max=100) + + class HourlyRateFactory(factory.django.DjangoModelFactory): """HourlyRate factory.""" diff --git a/datahub/omis/order/test/test_models.py b/datahub/omis/order/test/test_models.py index a5411118a..5c73667e2 100644 --- a/datahub/omis/order/test/test_models.py +++ b/datahub/omis/order/test/test_models.py @@ -17,8 +17,10 @@ from datahub.omis.quote.models import Quote from .factories import ( + OrderAssigneeCompleteFactory, OrderAssigneeFactory, OrderFactory, + OrderPaidFactory, OrderWithAcceptedQuoteFactory, OrderWithOpenQuoteFactory, ) @@ -273,7 +275,7 @@ def test_ok_if_order_in_allowed_status(self, allowed_status): try: order.reopen(by=AdviserFactory()) except Exception: - pytest.fail('Should not raise a validator error.') + pytest.fail('Should not raise any exception.') assert order.status == OrderStatus.draft @@ -330,7 +332,7 @@ def test_ok_if_order_in_allowed_status(self, allowed_status): try: order.accept_quote(by=contact) except Exception: - pytest.fail('Should not raise a validator error.') + pytest.fail('Should not raise any exception.') order.refresh_from_db() assert order.status == OrderStatus.quote_accepted @@ -401,10 +403,11 @@ def test_ok_if_order_in_allowed_status(self, allowed_status): ] ) except Exception: - pytest.fail('Should not raise a validator error.') + pytest.fail('Should not raise any exception.') order.refresh_from_db() assert order.status == OrderStatus.paid + assert order.paid_on == dateutil_parse('2017-01-02') assert list( order.payments.order_by('received_on').values_list('amount', 'received_on') ) == [ @@ -451,6 +454,7 @@ def test_atomicity(self): order.refresh_from_db() assert order.status == OrderStatus.quote_accepted + assert not order.paid_on assert not Payment.objects.count() def test_validation_error_if_amounts_less_then_total_cost(self): @@ -470,6 +474,82 @@ def test_validation_error_if_amounts_less_then_total_cost(self): ) +class TestCompleteOrder: + """Tests for when an order is marked as complete.""" + + @pytest.mark.parametrize( + 'allowed_status', + (OrderStatus.paid,) + ) + def test_ok_if_order_in_allowed_status(self, allowed_status): + """ + Test that the order can be marked as complete if it's in one of the allowed statuses. + """ + order = OrderPaidFactory(status=allowed_status, assignees=[]) + OrderAssigneeCompleteFactory(order=order) + adviser = AdviserFactory() + + try: + with freeze_time('2018-07-12 13:00'): + order.complete(by=adviser) + except Exception: + pytest.fail('Should not raise any exception.') + + order.refresh_from_db() + assert order.status == OrderStatus.complete + assert order.completed_on == dateutil_parse('2018-07-12 13:00') + assert order.completed_by == adviser + + @pytest.mark.parametrize( + 'disallowed_status', + ( + OrderStatus.draft, + OrderStatus.quote_awaiting_acceptance, + OrderStatus.quote_accepted, + OrderStatus.complete, + OrderStatus.cancelled, + ) + ) + def test_fails_if_order_not_in_allowed_status(self, disallowed_status): + """ + Test that if the order is in a disallowed status, the order cannot be marked as complete. + """ + order = OrderFactory(status=disallowed_status) + with pytest.raises(Conflict): + order.complete(by=None) + + assert order.status == disallowed_status + + def test_atomicity(self): + """ + Test that if there's a problem with saving the order, nothing gets saved. + """ + order = OrderPaidFactory(assignees=[]) + OrderAssigneeCompleteFactory(order=order) + with mock.patch.object(order, 'save') as mocked_save: + mocked_save.side_effect = Exception() + + with pytest.raises(Exception): + order.complete(by=None) + + order.refresh_from_db() + assert order.status == OrderStatus.paid + assert not order.completed_on + assert not order.completed_by + + def test_validation_error_if_not_all_actual_time_set(self): + """ + Test that if not all assignee actual time fields have been set, + a validation error is raised and the call fails. + """ + order = OrderPaidFactory(assignees=[]) + OrderAssigneeCompleteFactory(order=order) + OrderAssigneeFactory(order=order) + + with pytest.raises(ValidationError): + order.complete(by=None) + + class TestOrderAssignee: """Tests for the OrderAssignee model.""" diff --git a/datahub/omis/order/test/test_validators.py b/datahub/omis/order/test/test_validators.py index 5b1c45ab3..c56979abc 100644 --- a/datahub/omis/order/test/test_validators.py +++ b/datahub/omis/order/test/test_validators.py @@ -18,6 +18,7 @@ AddressValidator, AssigneeExistsRule, AssigneesFilledInValidator, + CompletableOrderValidator, ContactWorksAtCompanyValidator, NoOtherActiveQuoteExistsValidator, OrderDetailsFilledInValidator, @@ -591,6 +592,47 @@ def test_only_status_non_eu_succeeds(self, values_as_data, vat_status): pytest.fail('Should not raise a validator error.') +class TestCompletableOrderValidator: + """Tests for the CompletableOrderValidator.""" + + def test_ok_with_all_actual_time_fields_set(self): + """ + Test that the validation succeeds when all assignee.actual_time fields are set. + """ + order = mock.MagicMock() + order.assignees.all.return_value = ( + mock.MagicMock(actual_time=100), mock.MagicMock(actual_time=0) + ) + validator = CompletableOrderValidator() + validator.set_order(order) + + try: + validator() + except Exception: + pytest.fail('Should not raise a validator error.') + + def test_fails_if_not_all_actual_time_fields_set(self): + """ + Test that the validation fails if not all assignee.actual_time fields are set. + """ + order = mock.MagicMock() + order.assignees.all.return_value = ( + mock.MagicMock(actual_time=100), mock.MagicMock(actual_time=None) + ) + validator = CompletableOrderValidator() + validator.set_order(order) + + with pytest.raises(ValidationError) as exc: + validator() + + assert exc.value.detail == { + 'non_field_errors': ( + 'You must set the actual time for all assignees ' + 'to complete this order.' + ) + } + + class TestAddressValidator: """Tests for the AddressValidator.""" diff --git a/datahub/omis/order/test/views/test_order_details.py b/datahub/omis/order/test/views/test_order_details.py index 4d23cd6d0..07779dea1 100644 --- a/datahub/omis/order/test/views/test_order_details.py +++ b/datahub/omis/order/test/views/test_order_details.py @@ -12,7 +12,10 @@ from datahub.core.test_utils import APITestMixin from datahub.omis.market.models import Market -from ..factories import OrderFactory +from ..factories import ( + OrderAssigneeCompleteFactory, OrderAssigneeFactory, + OrderFactory, OrderPaidFactory +) from ...constants import OrderStatus, VATStatus from ...models import ServiceType @@ -133,6 +136,7 @@ def test_success_complete(self): 'name': Country.united_kingdom.value.name }, 'archived_documents_url_path': '', + 'paid_on': None, 'completed_by': None, 'completed_on': None, 'cancelled_by': None, @@ -526,6 +530,7 @@ def test_success(self): 'name': Country.united_kingdom.value.name }, 'archived_documents_url_path': '', + 'paid_on': None, 'completed_by': None, 'completed_on': None, 'cancelled_by': None, @@ -724,6 +729,7 @@ def test_cannot_change_readonly_fields(self): 'vat_cost': 99999, 'total_cost': 99999, 'archived_documents_url_path': '/documents/123', + 'paid_on': now().isoformat(), 'completed_by': order.created_by.pk, 'completed_on': now().isoformat(), 'cancelled_by': order.created_by.pk, @@ -750,6 +756,7 @@ def test_cannot_change_readonly_fields(self): assert response.json()['vat_cost'] != 99999 assert response.json()['total_cost'] != 99999 assert not response.json()['archived_documents_url_path'] + assert not response.json()['paid_on'] assert not response.json()['completed_by'] assert not response.json()['completed_on'] assert not response.json()['cancelled_by'] @@ -841,6 +848,79 @@ def test_fails_with_incomplete_billing_address(self): } +class TestMarkOrderAsComplete(APITestMixin): + """Test cases for marking an order as complete.""" + + @freeze_time('2017-04-18 13:00') + @pytest.mark.parametrize( + 'allowed_status', + (OrderStatus.paid,) + ) + def test_ok_if_order_in_allowed_status(self, allowed_status): + """Test changing an existing order.""" + order = OrderPaidFactory(status=allowed_status, assignees=[]) + OrderAssigneeCompleteFactory(order=order) + + url = reverse('api-v3:omis:order:complete', kwargs={'pk': order.pk}) + response = self.api_client.post(url, {}, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.json()['status'] == OrderStatus.complete + assert response.json()['completed_on'] == dateutil_parse('2017-04-18 13:00').isoformat() + assert response.json()['completed_by'] == { + 'id': str(self.user.pk), + 'name': self.user.name + } + + order.refresh_from_db() + assert order.status == OrderStatus.complete + assert order.completed_on == dateutil_parse('2017-04-18 13:00') + assert order.completed_by == self.user + + @pytest.mark.parametrize( + 'disallowed_status', + ( + OrderStatus.draft, + OrderStatus.quote_awaiting_acceptance, + OrderStatus.quote_accepted, + OrderStatus.complete, + OrderStatus.cancelled, + ) + ) + def test_409_if_order_not_in_allowed_status(self, disallowed_status): + """ + Test that if the order is in a disallowed status, the order cannot be marked as complete. + """ + order = OrderPaidFactory(status=disallowed_status, assignees=[]) + OrderAssigneeCompleteFactory(order=order) + + url = reverse('api-v3:omis:order:complete', kwargs={'pk': order.pk}) + response = self.api_client.post(url, {}, format='json') + + assert response.status_code == status.HTTP_409_CONFLICT + order.refresh_from_db() + assert order.status == disallowed_status + + def test_400_if_not_all_actual_time_set(self): + """ + Test that if not all assignee actual time fields have been set, + a validation error is raised and the call fails. + """ + order = OrderPaidFactory(status=OrderStatus.paid, assignees=[]) + OrderAssigneeCompleteFactory(order=order) + OrderAssigneeFactory(order=order) + + url = reverse('api-v3:omis:order:complete', kwargs={'pk': order.pk}) + response = self.api_client.post(url, {}, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + 'non_field_errors': ( + 'You must set the actual time for all assignees to complete this order.' + ) + } + + class TestViewOrderDetails(APITestMixin): """View order details test case.""" @@ -921,6 +1001,7 @@ def test_get(self): 'name': order.billing_address_country.name }, 'archived_documents_url_path': order.archived_documents_url_path, + 'paid_on': None, 'completed_by': None, 'completed_on': None, 'cancelled_by': None, diff --git a/datahub/omis/order/test/views/test_public_order_details.py b/datahub/omis/order/test/views/test_public_order_details.py index 80364819c..06e399e96 100644 --- a/datahub/omis/order/test/views/test_public_order_details.py +++ b/datahub/omis/order/test/views/test_public_order_details.py @@ -79,6 +79,7 @@ def test_get(self, order_status): 'id': str(order.billing_address_country.pk), 'name': order.billing_address_country.name }, + 'paid_on': None, 'completed_on': None, } diff --git a/datahub/omis/order/urls.py b/datahub/omis/order/urls.py index afd3cfb40..782310a35 100644 --- a/datahub/omis/order/urls.py +++ b/datahub/omis/order/urls.py @@ -5,19 +5,21 @@ from .views import AssigneeView, OrderViewSet, PublicOrderViewSet, SubscriberListView # internal frontend API -order_collection = OrderViewSet.as_view({ - 'post': 'create' -}) - -order_item = OrderViewSet.as_view({ - 'get': 'retrieve', - 'patch': 'partial_update' -}) - - internal_frontend_urls = [ - url(r'^order$', order_collection, name='list'), - url(r'^order/(?P[0-9a-z-]{36})$', order_item, name='detail'), + url(r'^order$', OrderViewSet.as_view({'post': 'create'}), name='list'), + url( + r'^order/(?P[0-9a-z-]{36})$', + OrderViewSet.as_view({ + 'get': 'retrieve', + 'patch': 'partial_update' + }), + name='detail' + ), + url( + r'^order/(?P[0-9a-z-]{36})/complete$', + OrderViewSet.as_view({'post': 'complete'}), + name='complete' + ), url( r'^order/(?P[0-9a-z-]{36})/subscriber-list$', @@ -34,10 +36,10 @@ # public facing API -public_order_item = PublicOrderViewSet.as_view({ - 'get': 'retrieve' -}) - public_urls = [ - url(r'^order/(?P[0-9A-Za-z_\-]{50})$', public_order_item, name='detail'), + url( + r'^order/(?P[0-9A-Za-z_\-]{50})$', + PublicOrderViewSet.as_view({'get': 'retrieve'}), + name='detail' + ), ] diff --git a/datahub/omis/order/validators.py b/datahub/omis/order/validators.py index 3859eff94..c0db59283 100644 --- a/datahub/omis/order/validators.py +++ b/datahub/omis/order/validators.py @@ -2,6 +2,7 @@ from django.db import models from rest_framework.exceptions import ValidationError +from rest_framework.settings import api_settings from datahub.core.validate_utils import DataCombiner from datahub.core.validators import AbstractRule, BaseRule @@ -296,6 +297,30 @@ def __call__(self, data=None): ) +class CompletableOrderValidator: + """ + Validator which checks that the order can be completed, that is, + all the assignees have their actual_time field set. + """ + + message = 'You must set the actual time for all assignees to complete this order.' + + def __init__(self): + """Initialise the object.""" + self.order = None + + def set_order(self, order): + """Set the order attr to the selected one.""" + self.order = order + + def __call__(self): + """Validate that the actual_time field for all the assignees is set.""" + if any(assignee.actual_time is None for assignee in self.order.assignees.all()): + raise ValidationError({ + api_settings.NON_FIELD_ERRORS_KEY: self.message + }) + + class AddressValidator: """Validator for addresses.""" diff --git a/datahub/omis/order/views.py b/datahub/omis/order/views.py index 0c2e6140d..206d930cb 100644 --- a/datahub/omis/order/views.py +++ b/datahub/omis/order/views.py @@ -1,5 +1,6 @@ from django.http import Http404 +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -26,6 +27,19 @@ class OrderViewSet(CoreViewSetV3): 'primary_market', ) + def complete(self, request, *args, **kwargs): + """Complete an order.""" + serializer = self.get_serializer(self.get_object()) + serializer.complete() + return Response(serializer.data, status=status.HTTP_200_OK) + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + return { + **super().get_serializer_context(), + 'current_user': self.request.user, + } + class PublicOrderViewSet(CoreViewSetV3): """ViewSet for public facing order endpoint.""" diff --git a/datahub/omis/payment/constants.py b/datahub/omis/payment/constants.py index 371e10118..7bf0b0008 100644 --- a/datahub/omis/payment/constants.py +++ b/datahub/omis/payment/constants.py @@ -7,3 +7,10 @@ ('cheque', 'Cheque'), ('manual', 'Manual'), ) + + +RefundStatus = Choices( + ('requested', 'Requested'), + ('approved', 'Approved and Paid'), + ('rejected', 'Rejected'), +) diff --git a/datahub/omis/payment/migrations/0004_refund.py b/datahub/omis/payment/migrations/0004_refund.py new file mode 100644 index 000000000..c8994990c --- /dev/null +++ b/datahub/omis/payment/migrations/0004_refund.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-27 10:13 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('order', '0001_squashed_0030_cancellation'), + ('omis-payment', '0003_auto_20171019_1639'), + ] + + operations = [ + migrations.CreateModel( + name='Refund', + fields=[ + ('created_on', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_on', models.DateTimeField(auto_now=True, null=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('reference', models.CharField(max_length=100)), + ('status', models.CharField(choices=[('requested', 'Requested'), ('approved', 'Approved and Paid'), ('rejected', 'Rejected')], max_length=100)), + ('requested_on', models.DateTimeField()), + ('refund_reason', models.TextField(blank=True)), + ('requested_amount', models.PositiveIntegerField()), + ('level1_approved_on', models.DateTimeField(blank=True, null=True)), + ('level1_approval_notes', models.TextField(blank=True)), + ('level2_approved_on', models.DateTimeField(blank=True, null=True)), + ('level2_approval_notes', models.TextField(blank=True)), + ('method', models.CharField(blank=True, choices=[('card', 'Card'), ('bacs', 'BACS'), ('cheque', 'Cheque'), ('manual', 'Manual')], max_length=100, null=True)), + ('net_amount', models.PositiveIntegerField(blank=True, null=True)), + ('vat_amount', models.PositiveIntegerField(blank=True, null=True)), + ('total_amount', models.PositiveIntegerField(blank=True, null=True)), + ('rejection_reason', models.TextField(blank=True)), + ('additional_reference', models.CharField(blank=True, max_length=100)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('level1_approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), + ('level2_approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refunds', to='order.Order')), + ('payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='omis-payment.Payment')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('created_on',), + }, + ), + ] diff --git a/datahub/omis/payment/models.py b/datahub/omis/payment/models.py index 8f1c20991..528061fe4 100644 --- a/datahub/omis/payment/models.py +++ b/datahub/omis/payment/models.py @@ -5,7 +5,7 @@ from datahub.core.models import BaseModel -from .constants import PaymentMethod +from .constants import PaymentMethod, RefundStatus from .managers import PaymentManager @@ -75,3 +75,71 @@ class Meta: def __str__(self): """Human-readable representation""" return self.reference + + +class Refund(BaseModel): + """Details of a refund.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + order = models.ForeignKey( + 'order.Order', + on_delete=models.CASCADE, + related_name="%(class)ss", # noqa: Q000 + ) + reference = models.CharField(max_length=100) + status = models.CharField(max_length=100, choices=RefundStatus) + + requested_on = models.DateTimeField() + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, blank=True, + on_delete=models.PROTECT, + related_name='+' + ) + refund_reason = models.TextField(blank=True) + requested_amount = models.PositiveIntegerField() + + level1_approved_on = models.DateTimeField(blank=True, null=True) + level1_approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, blank=True, + on_delete=models.PROTECT, + related_name='+' + ) + level1_approval_notes = models.TextField(blank=True) + + level2_approved_on = models.DateTimeField(blank=True, null=True) + level2_approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, blank=True, + on_delete=models.PROTECT, + related_name='+' + ) + level2_approval_notes = models.TextField(blank=True) + + method = models.CharField( + max_length=100, + null=True, blank=True, + choices=PaymentMethod + ) + net_amount = models.PositiveIntegerField(null=True, blank=True) + vat_amount = models.PositiveIntegerField(null=True, blank=True) + total_amount = models.PositiveIntegerField(null=True, blank=True) + + rejection_reason = models.TextField(blank=True) + + # legacy fields + payment = models.ForeignKey( + Payment, + null=True, blank=True, + on_delete=models.SET_NULL, + related_name='+' + ) + additional_reference = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ('created_on', ) + + def __str__(self): + """Human-readable representation""" + return self.reference diff --git a/datahub/omis/payment/test/views/test_payments.py b/datahub/omis/payment/test/views/test_payments.py index 6c5f407bc..69d6bd7ff 100644 --- a/datahub/omis/payment/test/views/test_payments.py +++ b/datahub/omis/payment/test/views/test_payments.py @@ -1,6 +1,7 @@ import uuid from operator import itemgetter import pytest +from dateutil.parser import parse as dateutil_parse from freezegun import freeze_time from rest_framework import status from rest_framework.reverse import reverse @@ -112,6 +113,9 @@ def test_create(self, order_status): 'received_on': '2017-04-21' } ] + order.refresh_from_db() + assert order.status == OrderStatus.paid + assert order.paid_on == dateutil_parse('2017-04-21') def test_400_if_amounts_less_than_total_cost(self): """ diff --git a/datahub/search/company/test/test_elasticsearch.py b/datahub/search/company/test/test_elasticsearch.py index 101ec685a..c6410cc52 100644 --- a/datahub/search/company/test/test_elasticsearch.py +++ b/datahub/search/company/test/test_elasticsearch.py @@ -175,6 +175,10 @@ def test_get_basic_search_query(): } } } + }, { + 'match': { + 'project_code': 'test' + } }, { 'match_phrase': { 'reference_trigram': 'test' diff --git a/datahub/search/contact/test/test_elasticsearch.py b/datahub/search/contact/test/test_elasticsearch.py index 1f4e6c01f..432ef2310 100644 --- a/datahub/search/contact/test/test_elasticsearch.py +++ b/datahub/search/contact/test/test_elasticsearch.py @@ -174,6 +174,10 @@ def test_get_basic_search_query(): } } } + }, { + 'match': { + 'project_code': 'test' + } }, { 'match_phrase': { 'reference_trigram': 'test' diff --git a/datahub/search/investment/models.py b/datahub/search/investment/models.py index 38b9b93af..68e010ae3 100644 --- a/datahub/search/investment/models.py +++ b/datahub/search/investment/models.py @@ -129,6 +129,7 @@ class InvestmentProject(DocType, MapDBModelToDict): 'business_activities.name', 'intermediate_company.name', 'investor_company.name', + 'project_code', 'sector.name', 'uk_company.name', ] diff --git a/datahub/search/investment/test/test_elasticsearch.py b/datahub/search/investment/test/test_elasticsearch.py index d90a1961c..8ab6a885e 100644 --- a/datahub/search/investment/test/test_elasticsearch.py +++ b/datahub/search/investment/test/test_elasticsearch.py @@ -176,6 +176,10 @@ def test_get_basic_search_query(): } } } + }, { + 'match': { + 'project_code': 'test' + } }, { 'match_phrase': { 'reference_trigram': 'test' @@ -388,6 +392,10 @@ def test_limited_get_search_by_entity_query(): } } } + }, { + 'match': { + 'project_code': 'test' + } }, { 'nested': { 'path': 'sector', diff --git a/datahub/search/investment/test/test_views.py b/datahub/search/investment/test/test_views.py index fb520d8f1..330260f83 100644 --- a/datahub/search/investment/test/test_views.py +++ b/datahub/search/investment/test/test_views.py @@ -14,45 +14,48 @@ @pytest.fixture -def setup_data(): +def setup_data(setup_es): """Sets up data for the tests.""" - InvestmentProjectFactory( - name='abc defg', - description='investmentproject1', - estimated_land_date=datetime.datetime(2011, 6, 13, 9, 44, 31, 62870), - investor_company=CompanyFactory( - registered_address_country_id=constants.Country.united_states.value.id - ), - status=InvestmentProject.STATUSES.ongoing, - uk_region_locations=[ - constants.UKRegion.east_midlands.value.id, - constants.UKRegion.isle_of_man.value.id, - ] - ) - InvestmentProjectFactory( - name='delayed project', - description='investmentproject2', - estimated_land_date=datetime.datetime(2057, 6, 13, 9, 44, 31, 62870), - investor_company=CompanyFactory( - registered_address_country_id=constants.Country.japan.value.id + investment_projects = [ + InvestmentProjectFactory( + name='abc defg', + description='investmentproject1', + estimated_land_date=datetime.datetime(2011, 6, 13, 9, 44, 31, 62870), + investor_company=CompanyFactory( + registered_address_country_id=constants.Country.united_states.value.id + ), + status=InvestmentProject.STATUSES.ongoing, + uk_region_locations=[ + constants.UKRegion.east_midlands.value.id, + constants.UKRegion.isle_of_man.value.id, + ] ), - project_manager=AdviserFactory(), - project_assurance_adviser=AdviserFactory(), - fdi_value_id=constants.FDIValue.higher.value.id, - status=InvestmentProject.STATUSES.delayed, - uk_region_locations=[ - constants.UKRegion.north_west.value.id, - ] - ) + InvestmentProjectFactory( + name='delayed project', + description='investmentproject2', + estimated_land_date=datetime.datetime(2057, 6, 13, 9, 44, 31, 62870), + investor_company=CompanyFactory( + registered_address_country_id=constants.Country.japan.value.id + ), + project_manager=AdviserFactory(), + project_assurance_adviser=AdviserFactory(), + fdi_value_id=constants.FDIValue.higher.value.id, + status=InvestmentProject.STATUSES.delayed, + uk_region_locations=[ + constants.UKRegion.north_west.value.id, + ] + ) + ] + setup_es.indices.refresh() + + yield investment_projects class TestSearch(APITestMixin): """Tests search views.""" - def test_search_investment_project_json(self, setup_es, setup_data): + def test_search_investment_project_json(self, setup_data): """Tests detailed investment project search.""" - setup_es.indices.refresh() - url = reverse('api-v3:search:investment_project') response = self.api_client.post(url, { @@ -64,10 +67,8 @@ def test_search_investment_project_json(self, setup_es, setup_data): assert len(response.data['results']) == 1 assert response.data['results'][0]['name'] == 'abc defg' - def test_search_investment_project_date_json(self, setup_es, setup_data): + def test_search_investment_project_date_json(self, setup_data): """Tests detailed investment project search.""" - setup_es.indices.refresh() - url = reverse('api-v3:search:investment_project') response = self.api_client.post(url, { @@ -78,10 +79,8 @@ def test_search_investment_project_date_json(self, setup_es, setup_data): assert response.data['count'] == 1 assert len(response.data['results']) == 1 - def test_search_investment_project_invalid_date_json(self, setup_es, setup_data): + def test_search_investment_project_invalid_date_json(self, setup_data): """Tests detailed investment project search.""" - setup_es.indices.refresh() - url = reverse('api-v3:search:investment_project') response = self.api_client.post(url, { @@ -90,10 +89,8 @@ def test_search_investment_project_invalid_date_json(self, setup_es, setup_data) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_search_investment_project_status(self, setup_es, setup_data): + def test_search_investment_project_status(self, setup_data): """Tests investment project search status filter.""" - setup_es.indices.refresh() - url = reverse('api-v3:search:investment_project') response = self.api_client.post(url, { @@ -105,10 +102,8 @@ def test_search_investment_project_status(self, setup_es, setup_data): assert len(response.data['results']) == 1 assert response.data['results'][0]['name'] == 'delayed project' - def test_search_investment_project_investor_country(self, setup_es, setup_data): + def test_search_investment_project_investor_country(self, setup_data): """Tests investor company country filter.""" - setup_es.indices.refresh() - url = reverse('api-v3:search:investment_project') response = self.api_client.post(url, { @@ -120,10 +115,8 @@ def test_search_investment_project_investor_country(self, setup_es, setup_data): assert len(response.data['results']) == 1 assert response.data['results'][0]['name'] == 'delayed project' - def test_search_investment_project_uk_region_location(self, setup_es, setup_data): + def test_search_investment_project_uk_region_location(self, setup_data): """Tests uk_region_location filter.""" - setup_es.indices.refresh() - url = reverse('api-v3:search:investment_project') response = self.api_client.post(url, { @@ -135,17 +128,15 @@ def test_search_investment_project_uk_region_location(self, setup_es, setup_data assert len(response.data['results']) == 1 assert response.data['results'][0]['name'] == 'abc defg' - def test_search_investment_project_no_filters(self, setup_es, setup_data): + def test_search_investment_project_no_filters(self, setup_data): """Tests case where there is no filters provided.""" - setup_es.indices.refresh() - url = reverse('api-v3:search:investment_project') response = self.api_client.post(url, {}) assert response.status_code == status.HTTP_200_OK assert len(response.data['results']) > 0 - def test_search_investment_project_multiple_filters(self, setup_es, setup_data): + def test_search_investment_project_multiple_filters(self, setup_es): """Tests multiple filters in investment project search. We make sure that out of provided investment projects, we will receive only those that match our filter. @@ -219,7 +210,7 @@ def test_search_investment_project_multiple_filters(self, setup_es, setup_data): for investment_project in response.data['results'] } - def test_search_investment_project_aggregates(self, setup_es, setup_data): + def test_search_investment_project_aggregates(self, setup_es): """Tests aggregates in investment project search.""" url = reverse('api-v3:search:investment_project') @@ -264,10 +255,8 @@ def test_search_investment_project_aggregates(self, setup_es, setup_data): class TestBasicSearch(APITestMixin): """Tests basic search view.""" - def test_investment_projects(self, setup_es, setup_data): + def test_investment_projects(self, setup_data): """Tests basic aggregate investment project query.""" - setup_es.indices.refresh() - term = 'abc defg' url = reverse('api-v3:search:basic') @@ -280,3 +269,17 @@ def test_investment_projects(self, setup_es, setup_data): assert response.data['count'] == 1 assert response.data['results'][0]['name'] == term assert [{'count': 1, 'entity': 'investment_project'}] == response.data['aggregations'] + + def test_project_code_search(self, setup_data): + """Tests basic search query for project code.""" + investment_project = setup_data[0] + + url = reverse('api-v3:search:basic') + response = self.api_client.get(url, { + 'term': investment_project.project_code, + 'entity': 'investment_project' + }) + + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 1 + assert response.data['results'][0]['project_code'] == investment_project.project_code diff --git a/datahub/search/omis/models.py b/datahub/search/omis/models.py index 7ac1585d5..6832aa201 100644 --- a/datahub/search/omis/models.py +++ b/datahub/search/omis/models.py @@ -40,6 +40,7 @@ class Order(DocType, MapDBModelToDict): total_cost_string = Keyword() total_cost = Integer(copy_to=['total_cost_string']) payment_due_date = Date() + paid_on = Date() completed_by = dsl_utils.contact_or_adviser_mapping('completed_by') completed_on = Date() cancelled_by = dsl_utils.contact_or_adviser_mapping('cancelled_by') @@ -92,6 +93,7 @@ class Order(DocType, MapDBModelToDict): 'public_token', 'invoice', 'payments', + 'refunds', 'archived_documents_url_path', ) diff --git a/datahub/search/omis/test/test_elasticsearch.py b/datahub/search/omis/test/test_elasticsearch.py index 3940fc4e0..f5b93e6c7 100644 --- a/datahub/search/omis/test/test_elasticsearch.py +++ b/datahub/search/omis/test/test_elasticsearch.py @@ -4,7 +4,8 @@ from datahub.omis.order.test.factories import ( OrderCancelledFactory, OrderCompleteFactory, - OrderFactory, OrderWithAcceptedQuoteFactory + OrderFactory, OrderPaidFactory, + OrderWithAcceptedQuoteFactory ) from .. import OrderSearchApp @@ -336,6 +337,9 @@ def test_mapping(setup_es): 'index': False, 'type': 'boolean' }, + 'paid_on': { + 'type': 'date' + }, 'completed_by': { 'properties': { 'first_name': { @@ -408,7 +412,13 @@ def test_mapping(setup_es): @pytest.mark.parametrize( 'Factory', # noqa: N803 - (OrderFactory, OrderWithAcceptedQuoteFactory, OrderCancelledFactory, OrderCompleteFactory) + ( + OrderFactory, + OrderWithAcceptedQuoteFactory, + OrderCancelledFactory, + OrderCompleteFactory, + OrderPaidFactory, + ) ) def test_indexed_doc(Factory, setup_es): """Test the ES data of an indexed order.""" @@ -518,6 +528,7 @@ def test_indexed_doc(Factory, setup_es): 'id': str(order.billing_address_country.pk), 'name': order.billing_address_country.name }, + 'paid_on': order.paid_on.isoformat() if order.paid_on else None, 'completed_by': { 'id': str(order.completed_by.pk), 'first_name': order.completed_by.first_name, diff --git a/datahub/search/omis/test/test_models.py b/datahub/search/omis/test/test_models.py index 7c44e5f56..b40bb5f5f 100644 --- a/datahub/search/omis/test/test_models.py +++ b/datahub/search/omis/test/test_models.py @@ -2,7 +2,8 @@ from datahub.omis.order.test.factories import ( OrderAssigneeFactory, OrderCancelledFactory, OrderCompleteFactory, - OrderFactory, OrderSubscriberFactory, OrderWithAcceptedQuoteFactory + OrderFactory, OrderPaidFactory, OrderSubscriberFactory, + OrderWithAcceptedQuoteFactory ) from ..models import Order as ESOrder @@ -12,7 +13,13 @@ @pytest.mark.parametrize( 'Factory', # noqa: N803 - (OrderCancelledFactory, OrderCompleteFactory, OrderFactory, OrderWithAcceptedQuoteFactory) + ( + OrderCancelledFactory, + OrderCompleteFactory, + OrderFactory, + OrderPaidFactory, + OrderWithAcceptedQuoteFactory + ) ) def test_order_to_dict(Factory, setup_es): """Test converting an order to dict.""" @@ -113,6 +120,7 @@ def test_order_to_dict(Factory, setup_es): 'id': str(order.billing_address_country.pk), 'name': order.billing_address_country.name }, + 'paid_on': order.paid_on, 'completed_by': { 'id': str(order.completed_by.pk), 'first_name': order.completed_by.first_name,