Skip to content

Commit

Permalink
Merge pull request #5496 from uktrade/feature/CLS2-849-investment-pro…
Browse files Browse the repository at this point in the history
…jects-record-multiple-programmes-part-3

Feature/cls2 849 investment projects record multiple programmes part 3
  • Loading branch information
Richard-Pentecost authored Jun 21, 2024
2 parents 901055c + 6b964ae commit 1dba7f5
Show file tree
Hide file tree
Showing 7 changed files with 384 additions and 6 deletions.
5 changes: 4 additions & 1 deletion datahub/investment/project/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ class InvestmentProjectAdmin(BaseModelAdminMixin, VersionAdmin):
'financial_year_verbose',
# Remove when migration to specific_programmes is complete
'specific_programme',
'specific_programmes',
)
list_display = (
'name',
Expand Down Expand Up @@ -91,6 +90,10 @@ def save_model(self, request, obj, form, change):
obj.project_manager_first_assigned_on = now()
obj.project_manager_first_assigned_by = request.user

if 'specific_programmes' in form.cleaned_data:
specific_programmes = form.cleaned_data['specific_programmes']
obj.specific_programme = specific_programmes[0] if specific_programmes else None

super().save_model(request, obj, form, change)


Expand Down
61 changes: 58 additions & 3 deletions datahub/investment/project/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from rest_framework import serializers
from rest_framework.settings import api_settings

import datahub.metadata.models as meta_models
from datahub.company.models import Company, Contact
Expand Down Expand Up @@ -68,6 +69,7 @@
'investor_company_country',
'intermediate_company',
'level_of_involvement',
# TODO: Remove specific_programme following depracation
'specific_programme',
'specific_programmes',
'client_contacts',
Expand Down Expand Up @@ -297,6 +299,32 @@ def validate_note(self, value):
return activity.validated_data


class _ManyRelatedAsSingleItemField(NestedRelatedField):
"""
Serializer field that makes to-many field behave like a to-one field.
Use for temporary backwards compatibility when migrating a to-one field to be a to-many field
(so that a to-one field can emulated using a to-many field).
This isn't intended to be used in any other way as if the to-many field contains multiple
items, only one of them will be returned, and all of them will overwritten on updates.
TODO: Remove this once specific_programme has been removed from investment projects.
"""

def run_validation(self, data=serializers.empty):
"""Validate a user-provided value and return the internal value (converted to a list)"""
validated_value = super().run_validation(data)
return [validated_value] if validated_value else []

def to_representation(self, value):
"""Converts a query set to a dict representation of the first item in the query set."""
if not value.exists():
return None

return super().to_representation(value.first())


class IProjectSerializer(PermittedFieldsModelSerializer, NoteAwareModelSerializer):
"""Serialiser for investment project endpoints."""

Expand All @@ -308,6 +336,9 @@ class IProjectSerializer(PermittedFieldsModelSerializer, NoteAwareModelSerialize
'only_ivt_can_move_to_won': gettext_lazy(
'Only the Investment Verification Team can move the project to the ‘Won’ stage.',
),
'one_specific_programme_field': gettext_lazy(
'Only one of specific_programme and specific_programmes should be provided.',
),
}

project_code = serializers.CharField(read_only=True)
Expand All @@ -325,7 +356,12 @@ class IProjectSerializer(PermittedFieldsModelSerializer, NoteAwareModelSerialize
intermediate_company = NestedRelatedField(Company, required=False, allow_null=True)
level_of_involvement = NestedRelatedField(Involvement, required=False, allow_null=True)
likelihood_to_land = NestedRelatedField(LikelihoodToLand, required=False, allow_null=True)
specific_programme = NestedRelatedField(SpecificProgramme, required=False, allow_null=True)
specific_programme = _ManyRelatedAsSingleItemField(
SpecificProgramme,
source='specific_programmes',
required=False,
allow_null=True,
)
specific_programmes = NestedRelatedField(SpecificProgramme, many=True, required=False)
client_contacts = NestedRelatedField(
Contact,
Expand Down Expand Up @@ -422,6 +458,24 @@ class IProjectSerializer(PermittedFieldsModelSerializer, NoteAwareModelSerialize
proposal_deadline = serializers.DateField(required=False, allow_null=True)
stage_log = NestedInvestmentProjectStageLogSerializer(many=True, read_only=True)

def to_internal_value(self, data):
"""
Checks that specific_programme and specific_programmes haven't both been provided.
Note: On serializers, to_internal_value() is called before validate().
TODO: Remove once specific_programme is removed from the API.
"""
if 'specific_programme' in data and 'specific_programmes' in data:
error = {
api_settings.NON_FIELD_ERRORS_KEY: [
self.error_messages['one_specific_programme_field'],
],
}
raise serializers.ValidationError(error, code='one_specific_programme_field')

return super().to_internal_value(data)

def save(self, **kwargs):
"""Saves when and who assigned a project manager for the first time."""
if 'project_manager' in self.validated_data and (
Expand Down Expand Up @@ -460,8 +514,9 @@ def validate(self, data):
if update_status:
data['status'] = update_status

if 'specific_programme' in data and data['specific_programme'] is not None:
data['specific_programmes'] = [data['specific_programme']]
if 'specific_programmes' in data:
specific_programmes = data['specific_programmes']
data['specific_programme'] = specific_programmes[0] if specific_programmes else None

self._track_project_manager_request(data)
return data
Expand Down
94 changes: 93 additions & 1 deletion datahub/investment/project/test/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
from datahub.core import constants
from datahub.core.test_utils import AdminTestMixin
from datahub.investment.project.admin import InvestmentProjectAdmin
from datahub.investment.project.constants import FDISICGrouping as FDISICGroupingConstant
from datahub.investment.project.constants import (
FDISICGrouping as FDISICGroupingConstant,
SpecificProgramme,
)

from datahub.investment.project.models import (
GVAMultiplier,
Expand Down Expand Up @@ -305,3 +308,92 @@ def test_creating_project_sets_gross_value_added(self):

# GVA Multiplier - Aircraft - 2022
assert investment_project.gva_multiplier.multiplier == Decimal('0.209650945')

def test_add_specific_programmes(self):
"""
Test that adding an investment project with specific_programmes values also sets the
specific_programme field.
"""
url = reverse('admin:investment_investmentproject_add')

investment_project_pk = str(uuid4())
specific_programmes = [UUID(SpecificProgramme.innovation_gateway.value.id), UUID(
SpecificProgramme.space.value.id)]
data = {
'name': 'name 318',
'description': 'desc 318',
'investment_type': str(constants.InvestmentType.fdi.value.id),
'stage': str(constants.InvestmentProjectStage.active.value.id),
'status': 'ongoing',
'project_manager': str(AdviserFactory().pk),
'proposal_deadline': '2017-04-19',
'specific_programmes': specific_programmes,
'id': investment_project_pk,
}
response = self.client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK

investment_project = InvestmentProject.objects.get(pk=investment_project_pk)
assert set(investment_project.specific_programmes.all().values_list(
'id', flat=True)) == set(specific_programmes)

assert investment_project.specific_programme_id == specific_programmes[0]

def test_update_specific_programme_to_non_null(self):
"""
Test that updating an investment project with a specific_programmes value also sets the
specific_programme field.
"""
investment_project = InvestmentProjectFactory()
url = reverse('admin:investment_investmentproject_change', args=(investment_project.pk,))

specific_programmes = [UUID(SpecificProgramme.innovation_gateway.value.id), UUID(
SpecificProgramme.space.value.id)]

data = {
'name': 'name 318',
'description': 'desc 318',
'investment_type': str(constants.InvestmentType.fdi.value.id),
'stage': str(constants.InvestmentProjectStage.active.value.id),
'status': 'ongoing',
'project_manager': str(AdviserFactory().pk),
'proposal_deadline': '2017-04-19',
'id': investment_project.pk,
'specific_programmes': specific_programmes,
}

response = self.client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK

investment_project.refresh_from_db()
assert set(investment_project.specific_programmes.all().values_list(
'id', flat=True)) == set(specific_programmes)
assert investment_project.specific_programme_id == specific_programmes[0]

def test_update_specific_programme_to_null(self):
"""
Test that removing all specific_programmes from an investment project clears
the specific_programme field.
"""
investment_project = InvestmentProjectFactory()
url = reverse('admin:investment_investmentproject_change', args=(investment_project.pk,))

data = {
'name': 'name 318',
'description': 'desc 318',
'investment_type': str(constants.InvestmentType.fdi.value.id),
'stage': str(constants.InvestmentProjectStage.active.value.id),
'status': 'ongoing',
'project_manager': str(AdviserFactory().pk),
'proposal_deadline': '2017-04-19',
'id': investment_project.pk,
'specific_programmes': [],
}

response = self.client.post(url, data=data, follow=True)

assert response.status_code == status.HTTP_200_OK

investment_project.refresh_from_db()
assert investment_project.specific_programme is None
assert investment_project.specific_programmes.count() == 0
47 changes: 47 additions & 0 deletions datahub/investment/project/test/test_serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from unittest.mock import Mock
from uuid import uuid4

import pytest


from datahub.company.test.factories import AdviserFactory
from datahub.core import constants
from datahub.core.test_utils import MockQuerySet
from datahub.investment.project.serializers import (
_ManyRelatedAsSingleItemField,
IProjectSerializer,
IProjectTeamMemberListSerializer,
IProjectTeamMemberSerializer,
Expand Down Expand Up @@ -166,3 +171,45 @@ def test_country_investment_originates_from(self):
assert str(
serializer.validated_data['country_investment_originates_from'],
) == constants.Country.argentina.value.name


class TestManyRelatedAsSingleItemField:
"""Tests fo _ManyRelatedAsSingleItemField."""

MOCK_ITEM_1 = Mock(pk=uuid4())
MOCK_ITEM_2 = Mock(pk=uuid4())

@pytest.mark.parametrize(
'value,expected',
(
(None, []),
({'id': str(MOCK_ITEM_1.pk)}, [MOCK_ITEM_1]),
),
)
def test_run_validation(self, value, expected):
"""Test that run_vlaidation() returns a list."""
model = Mock(objects=MockQuerySet([self.MOCK_ITEM_1]))
field = _ManyRelatedAsSingleItemField(model, allow_null=True)
assert field.run_validation(value) == expected

@pytest.mark.parametrize(
'value,expected',
(
(
MockQuerySet([]),
None,
),
(
MockQuerySet([MOCK_ITEM_1]),
{'id': str(MOCK_ITEM_1.pk), 'name': MOCK_ITEM_1.name},
),
(
MockQuerySet([MOCK_ITEM_1, MOCK_ITEM_2]),
{'id': str(MOCK_ITEM_1.pk), 'name': MOCK_ITEM_1.name},
),
),
)
def test_to_representation(self, value, expected):
"""Tests that to_representation() returns a single item as a dict."""
field = _ManyRelatedAsSingleItemField(Mock())
assert field.to_representation(value) == expected
Loading

0 comments on commit 1dba7f5

Please sign in to comment.