diff --git a/datahub/cleanup/management/commands/delete_old_records.py b/datahub/cleanup/management/commands/delete_old_records.py index f1672eed5..450a1e891 100644 --- a/datahub/cleanup/management/commands/delete_old_records.py +++ b/datahub/cleanup/management/commands/delete_old_records.py @@ -122,9 +122,7 @@ class Command(BaseCleanupCommand): DatetimeLessThanCleanupFilter('created_on', COMPANY_EXPIRY_PERIOD), DatetimeLessThanCleanupFilter('modified_on', COMPANY_EXPIRY_PERIOD), ), - excluded_relations=( - CompanyReferral._meta.get_field('activity'), - ), + excluded_relations=(CompanyReferral._meta.get_field('activity'),), ), 'interaction.Interaction': ModelCleanupConfig( (DatetimeLessThanCleanupFilter('date', INTERACTION_EXPIRY_PERIOD),), @@ -250,6 +248,7 @@ class Command(BaseCleanupCommand): excluded_relations=( Order._meta.get_field('assignees'), Order._meta.get_field('subscribers'), + Order._meta.get_field('activity'), ), ), 'company.CompanyExport': ModelCleanupConfig( diff --git a/datahub/cleanup/test/commands/test_delete_old_records.py b/datahub/cleanup/test/commands/test_delete_old_records.py index 36c693d9c..4344d7014 100644 --- a/datahub/cleanup/test/commands/test_delete_old_records.py +++ b/datahub/cleanup/test/commands/test_delete_old_records.py @@ -34,8 +34,11 @@ OneListCoreTeamMemberFactory, SubsidiaryFactory, ) -from datahub.company_activity.tests.factories import CompanyActivityInteractionFactory -from datahub.company_activity.tests.factories import CompanyActivityInvestmentProjectFactory +from datahub.company_activity.tests.factories import ( + CompanyActivityInteractionFactory, + CompanyActivityInvestmentProjectFactory, + CompanyActivityOmisOrderFactory, +) from datahub.company_referral.test.factories import ( CompanyReferralFactory, CompleteCompanyReferralFactory, @@ -212,6 +215,12 @@ 'expired_objects_kwargs': [], 'unexpired_objects_kwargs': [], }, + { + 'factory': CompanyActivityOmisOrderFactory, + 'field': 'company', + 'expired_objects_kwargs': [], + 'unexpired_objects_kwargs': [], + }, { 'factory': EYBLeadFactory, 'field': 'company', @@ -681,6 +690,7 @@ 'omis_payment.Payment', 'omis_payment.Refund', 'omis_payment.PaymentGatewaySession', + 'company_activity.CompanyActivity', }, 'expired_objects_kwargs': [ { diff --git a/datahub/cleanup/test/commands/test_delete_orphaned_versions.py b/datahub/cleanup/test/commands/test_delete_orphaned_versions.py index 6b2f364de..0c528d85d 100644 --- a/datahub/cleanup/test/commands/test_delete_orphaned_versions.py +++ b/datahub/cleanup/test/commands/test_delete_orphaned_versions.py @@ -110,6 +110,7 @@ CompanyReferralFactory, CompanyInteractionFactory, InvestmentProjectFactory, + OrderFactory, ] diff --git a/datahub/cleanup/test/commands/test_delete_orphans.py b/datahub/cleanup/test/commands/test_delete_orphans.py index 49d8eb8eb..d08587359 100644 --- a/datahub/cleanup/test/commands/test_delete_orphans.py +++ b/datahub/cleanup/test/commands/test_delete_orphans.py @@ -94,7 +94,7 @@ ), 'implicit_related_models': (), 'ignored_models': ( - # Ignored as deleted with interactions, investments and referrals + # Ignored as deleted with interactions, investments, orders and referrals ('company_activity.CompanyActivity', 'company'), ), }, diff --git a/datahub/company/merge_company.py b/datahub/company/merge_company.py index 7b6d6d617..a07eb5d1c 100644 --- a/datahub/company/merge_company.py +++ b/datahub/company/merge_company.py @@ -163,7 +163,7 @@ def merge_companies(source_company: Company, target_company: Company, user): results[CompanyActivity] = { 'company': results[Interaction]['company'] + results[InvestmentProject]['investor_company'] - + results[CompanyReferral]['company'], + + results[CompanyReferral]['company'] + results[Order]['company'], } source_company.mark_as_transferred( diff --git a/datahub/company/test/test_merge_company.py b/datahub/company/test/test_merge_company.py index a21cdf35f..47edce107 100644 --- a/datahub/company/test/test_merge_company.py +++ b/datahub/company/test/test_merge_company.py @@ -221,6 +221,7 @@ class TestDuplicateCompanyMerger: company_with_orders_factory, { **base_expected_results, + CompanyActivity: {'company': 3}, Contact: {'company': 3}, Order: {'company': 3}, diff --git a/datahub/company_activity/migrations/0007_companyactivity_order_and_more.py b/datahub/company_activity/migrations/0007_companyactivity_order_and_more.py new file mode 100644 index 000000000..715367d88 --- /dev/null +++ b/datahub/company_activity/migrations/0007_companyactivity_order_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.16 on 2024-10-14 08:22 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0014_alter_order_company_alter_order_contact_and_more'), + ('company_activity', '0006_alter_great_data_comment_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companyactivity', + name='order', + field=models.ForeignKey(blank=True, help_text='If related to an omis order, must not have relations to any other activity (referral, event etc)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity', to='order.order', unique=True), + ), + migrations.AlterField( + model_name='companyactivity', + name='activity_source', + field=models.CharField(choices=[('interaction', 'interaction'), ('referral', 'referral'), ('event', 'event'), ('investment', 'investment'), ('order', 'order')], help_text='The type of company activity, such as an interaction, event, referral etc.', max_length=255), + ), + ] diff --git a/datahub/company_activity/models/company_activity.py b/datahub/company_activity/models/company_activity.py index 771809b31..d10bce561 100644 --- a/datahub/company_activity/models/company_activity.py +++ b/datahub/company_activity/models/company_activity.py @@ -28,6 +28,7 @@ class ActivitySource(models.TextChoices): referral = ('referral', 'referral') event = ('event', 'event') investment = ('investment', 'investment') + order = ('order', 'order') id = models.UUIDField(primary_key=True, default=uuid.uuid4) company = models.ForeignKey( @@ -85,6 +86,18 @@ class ActivitySource(models.TextChoices): 'investment project for company' ), ) + order = models.ForeignKey( + 'order.Order', + unique=True, + null=True, + blank=True, + related_name='activity', + on_delete=models.CASCADE, + help_text=( + 'If related to an omis order, must not have relations to any other activity ' + '(referral, event etc)' + ), + ) def __str__(self): """Readable name for CompanyActivity""" diff --git a/datahub/company_activity/tasks/sync.py b/datahub/company_activity/tasks/sync.py index 36cc3699b..a75889cf3 100644 --- a/datahub/company_activity/tasks/sync.py +++ b/datahub/company_activity/tasks/sync.py @@ -8,6 +8,7 @@ from datahub.core.queues.scheduler import LONG_RUNNING_QUEUE from datahub.interaction.models import Interaction from datahub.investment.project.models import InvestmentProject +from datahub.omis.order.models import Order logger = logging.getLogger(__name__) @@ -189,3 +190,59 @@ def relate_company_activity_to_investment_projects(batch_size=500): f'Creating in batches of: {batch_size} CompanyActivities. {total} remaining.') CompanyActivity.objects.bulk_create(objs=batch, batch_size=batch_size) total -= batch_size + + +def schedule_sync_order_to_company_activity(): + """ + Schedules a task to relate all `Omis Order`s to `CompanyActivity`s + + Can be used to populate the CompanyActivity with missing orders + or to initially populate the model. + """ + job = job_scheduler( + queue_name=LONG_RUNNING_QUEUE, + function=relate_company_activity_to_orders, + job_timeout=HALF_DAY_IN_SECONDS, + max_retries=5, + retry_backoff=True, + ) + logger.info( + f'Task {job.id} schedule_sync_order_to_company_activity scheduled.', + ) + return job + + +def relate_company_activity_to_orders(batch_size=500): + """ + Grabs all omis orders so they can be related to in the + `CompanyActivity` model with a bulk_create. Excludes any + order projects already associated in the CompanyActivity model. + """ + activity_orders = set( + CompanyActivity.objects.filter( + order__isnull=False, + ).values_list('order_id', flat=True), + ) + + orders = Order.objects.filter( + company_id__isnull=False, + ).values('id', 'created_on', 'company_id') + objs = ( + CompanyActivity( + order_id=order['id'], + date=order['created_on'], + company_id=order['company_id'], + activity_source=CompanyActivity.ActivitySource.order, + ) + for order in orders + if order['id'] not in activity_orders + ) + total = orders.count() + while True: + batch = list(islice(objs, batch_size)) + if not batch: + logger.info('Finished bulk creating CompanyActivities.') + break + logger.info(f'Creating in batches of: {batch_size} CompanyActivities. {total} remaining.') + CompanyActivity.objects.bulk_create(objs=batch, batch_size=batch_size) + total -= batch_size diff --git a/datahub/company_activity/tests/factories.py b/datahub/company_activity/tests/factories.py index 88d4a2fd3..4b48a58e0 100644 --- a/datahub/company_activity/tests/factories.py +++ b/datahub/company_activity/tests/factories.py @@ -7,6 +7,7 @@ from datahub.interaction.test.factories import CompanyInteractionFactory from datahub.investment.project.test.factories import InvestmentProjectFactory from datahub.metadata.test.factories import CountryFactory +from datahub.omis.order.test.factories import OrderFactory class CompanyActivityInteractionFactory(factory.django.DjangoModelFactory): @@ -23,6 +24,7 @@ class CompanyActivityInteractionFactory(factory.django.DjangoModelFactory): interaction = factory.SubFactory(CompanyInteractionFactory) referral = None investment = None + order = None class Meta: model = 'company_activity.CompanyActivity' @@ -49,6 +51,7 @@ class CompanyActivityReferralFactory(factory.django.DjangoModelFactory): referral = factory.SubFactory(CompanyReferralFactory) interaction = None investment = None + order = None class Meta: model = 'company_activity.CompanyActivity' @@ -75,6 +78,7 @@ class CompanyActivityInvestmentProjectFactory(factory.django.DjangoModelFactory) investment = factory.SubFactory(InvestmentProjectFactory) interaction = None referral = None + order = None class Meta: model = 'company_activity.CompanyActivity' @@ -90,6 +94,33 @@ def _create(cls, model_class, *args, **kwargs): return CompanyActivity.objects.get(investment_id=obj.investment_id) +class CompanyActivityOmisOrderFactory(factory.django.DjangoModelFactory): + """ + CompanyActivity factory with an omis order. + """ + + date = now() + activity_source = CompanyActivity.ActivitySource.order + company = factory.SubFactory(CompanyFactory) + investment = None + interaction = None + referral = None + order = factory.SubFactory(OrderFactory) + + class Meta: + model = 'company_activity.CompanyActivity' + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """ + Overwrite the _create to prevent the CompanyActivity from saving to the database. + This is due to the Omis Order already creating the CompanyActivity inside its + model save. + """ + obj = model_class(*args, **kwargs) + return CompanyActivity.objects.get(order_id=obj.order_id) + + class CompanyActivityIngestedFileFactory(factory.django.DjangoModelFactory): """ CompanyActivity ingested file factory diff --git a/datahub/company_activity/tests/test_tasks/test_order_task.py b/datahub/company_activity/tests/test_tasks/test_order_task.py new file mode 100644 index 000000000..0eaec7994 --- /dev/null +++ b/datahub/company_activity/tests/test_tasks/test_order_task.py @@ -0,0 +1,76 @@ +from unittest import mock + +import pytest + +from datahub.company_activity.models import CompanyActivity +from datahub.company_activity.tasks.sync import ( + relate_company_activity_to_orders, + schedule_sync_order_to_company_activity, +) +from datahub.omis.order.test.factories import OrderFactory + + +@pytest.mark.django_db +class TestCompanyActivityOrderTasks: + """ + Tests for the schedule_sync_investments_to_company_activity task. + """ + + def test_orders_are_copied_to_company_activity(self): + """ + Test that investments are added to the CompanyActivity model. + """ + orders = OrderFactory.create_batch(5) + + # Remove the created CompanyActivities added by the omis order `save` method + # to mimic already existing data in staging and prod database. + CompanyActivity.objects.all().delete() + assert CompanyActivity.objects.count() == 0 + + # Check the "existing" orders are added to the company activity model + schedule_sync_order_to_company_activity() + assert CompanyActivity.objects.count() == len(orders) + + company_activity = CompanyActivity.objects.get(order_id=orders[0].id) + assert company_activity.date == orders[0].created_on + assert company_activity.activity_source == CompanyActivity.ActivitySource.order + assert company_activity.company_id == orders[0].company.id + + @mock.patch('datahub.company_activity.models.CompanyActivity.objects.bulk_create') + def test_order_are_bulk_created_in_batches(self, mocked_bulk_create, caplog): + """ + Test that omis orders are bulk created in batches. + """ + caplog.set_level('INFO') + batch_size = 5 + + OrderFactory.create_batch(10) + + # Delete any activity created through the investments save method. + CompanyActivity.objects.all().delete() + assert CompanyActivity.objects.count() == 0 + + # Ensure omis orders are bulk_created + relate_company_activity_to_orders(batch_size) + assert mocked_bulk_create.call_count == 2 + + assert ( + f'Creating in batches of: {batch_size} CompanyActivities. 10 remaining.' in caplog.text + ) + assert ( + f'Creating in batches of: {batch_size} CompanyActivities. 5 remaining.' in caplog.text + ) + assert 'Finished bulk creating CompanyActivities.' in caplog.text + + def test_order_with_a_company_activity_are_not_added_again(self): + """ + Test that investment projects which are already part of the `CompanyActivity` model + are not added again. + """ + OrderFactory.create_batch(4) + + assert CompanyActivity.objects.count() == 4 + + # Check count remains unchanged. + schedule_sync_order_to_company_activity() + assert CompanyActivity.objects.count() == 4 diff --git a/datahub/interaction/test/test_models.py b/datahub/interaction/test/test_models.py index 3e05d9ccf..c0ed4ee6d 100644 --- a/datahub/interaction/test/test_models.py +++ b/datahub/interaction/test/test_models.py @@ -61,7 +61,7 @@ def test_save(self): Test save also saves to the `CompanyActivity` model. Test save does not save to the `CompanyActivity` model if it already exists. """ - assert CompanyActivity.objects.all().count() == 0 + assert not CompanyActivity.objects.all().exists() interaction = CompanyInteractionFactory() assert CompanyActivity.objects.all().count() == 1 diff --git a/datahub/omis/order/models.py b/datahub/omis/order/models.py index 6488868b5..1f76b4b60 100644 --- a/datahub/omis/order/models.py +++ b/datahub/omis/order/models.py @@ -11,6 +11,7 @@ from mptt.fields import TreeForeignKey from datahub.company.models import Advisor, Company, Contact +from datahub.company_activity.models import CompanyActivity from datahub.core import reversion from datahub.core.models import ( BaseConstantModel, @@ -355,7 +356,19 @@ def save(self, *args, **kwargs): self.reference = self.generate_reference() if not self.public_token: self.public_token = self.generate_public_token() - return super().save(*args, **kwargs) + + with transaction.atomic(): + super().save(*args, **kwargs) + if not self.company_id: + return + CompanyActivity.objects.update_or_create( + order_id=self.id, + activity_source=CompanyActivity.ActivitySource.order, + defaults={ + 'date': self.created_on, + 'company_id': self.company_id, + }, + ) def get_lead_assignee(self): """ diff --git a/datahub/omis/order/signal_receivers.py b/datahub/omis/order/signal_receivers.py index 9fa9b0aca..96f47f653 100644 --- a/datahub/omis/order/signal_receivers.py +++ b/datahub/omis/order/signal_receivers.py @@ -15,9 +15,25 @@ def update_order_pricing_on_pre_order_save(sender, instance, **kwargs): @receiver(post_save, sender=OrderAssignee) -@receiver(post_delete, sender=OrderAssignee) def update_order_pricing_on_related_obj_save(sender, instance, **kwargs): """ Update the order pricing after an order assignee is saved or deleted. """ update_order_pricing(instance.order, commit=True) + + +@receiver(post_delete, sender=OrderAssignee) +def update_order_pricing_if_assignee_removed(sender, instance, **kwargs): + """ + Update the order pricing after an order assignee is deleted. + + If the deletion comes from the Order, that means the Order has been + deleted so don't save the Order again. + """ + # If we hit the post_delete signal for `OrderAssignee` by deleting + # an Order, do nothing as the Order is being deleted. + origin = kwargs.get('origin') + if origin and origin == instance.order: + return + + update_order_pricing(instance.order, commit=True) diff --git a/datahub/omis/order/test/test_models.py b/datahub/omis/order/test/test_models.py index 1e1414dd0..54d76e6d6 100644 --- a/datahub/omis/order/test/test_models.py +++ b/datahub/omis/order/test/test_models.py @@ -12,6 +12,7 @@ from rest_framework.exceptions import ValidationError from datahub.company.test.factories import AdviserFactory, CompanyFactory, ContactFactory +from datahub.company_activity.models import CompanyActivity from datahub.core import constants from datahub.core.exceptions import APIConflictException from datahub.metadata.test.factories import TeamFactory @@ -764,3 +765,35 @@ def test_order_get_absolute_url(): assert order.get_absolute_url() == ( f'{settings.DATAHUB_FRONTEND_URL_PREFIXES["order"]}/{order.pk}' ) + + +@pytest.mark.django_db +class TestOrder: + """Tests for the Omis Order model.""" + + def test_save(self): + """ + Test save also saves to the `CompanyActivity` model and does not save to the + `CompanyActivity` model if it already exists. + """ + assert not CompanyActivity.objects.all().exists() + order = OrderFactory() + assert CompanyActivity.objects.all().count() == 1 + + company_activity = CompanyActivity.objects.get(order_id=order.id) + assert company_activity.company_id == order.company_id + assert company_activity.date == order.created_on + assert company_activity.activity_source == CompanyActivity.ActivitySource.order + + # Update and save the order and ensure if doesn't create another + # `CompanyActivity` and only updates it + new_company = CompanyFactory() + order.company_id = new_company.id + order.save() + + assert CompanyActivity.objects.all().count() == 1 + company_activity.refresh_from_db() + assert company_activity.company_id == new_company.id + + order.delete() + assert not CompanyActivity.objects.all().exists() diff --git a/datahub/search/company_activity/apps.py b/datahub/search/company_activity/apps.py index 64b9ac853..2a5325349 100644 --- a/datahub/search/company_activity/apps.py +++ b/datahub/search/company_activity/apps.py @@ -24,11 +24,15 @@ class CompanyActivitySearchApp(SearchApp): 'investment', 'investment__investment_type', 'investment__created_by', + 'order', + 'order__contact', + 'order__primary_market', + 'order__uk_region', + 'order__created_by', ).prefetch_related( 'interaction__contacts', Prefetch( 'interaction__dit_participants', - queryset=InteractionDITParticipant.objects.select_related( - 'adviser', 'team'), + queryset=InteractionDITParticipant.objects.select_related('adviser', 'team'), ), ) diff --git a/datahub/search/company_activity/dict_utils.py b/datahub/search/company_activity/dict_utils.py index 856ed9b87..4c559b21b 100644 --- a/datahub/search/company_activity/dict_utils.py +++ b/datahub/search/company_activity/dict_utils.py @@ -53,3 +53,19 @@ def activity_investment_dict(obj): 'created_by': dict_utils.contact_or_adviser_dict(obj.created_by), 'client_contacts': dict_utils.contact_job_list_of_dicts(obj.client_contacts), } + + +def activity_order_dict(obj): + """Creates dictionary from an omis order""" + if obj is None: + return None + + return { + 'id': str(obj.id), + 'created_on': obj.created_on, + 'reference': obj.reference, + 'primary_market': dict_utils.id_name_dict(obj.primary_market), + 'uk_region': dict_utils.id_name_dict(obj.uk_region), + 'contact': dict_utils.contact_job_dict(obj.contact), + 'created_by': dict_utils.contact_or_adviser_dict(obj.created_by), + } diff --git a/datahub/search/company_activity/fields.py b/datahub/search/company_activity/fields.py index 1a586f854..e6c044415 100644 --- a/datahub/search/company_activity/fields.py +++ b/datahub/search/company_activity/fields.py @@ -50,3 +50,17 @@ def activity_investment_field(): 'client_contacts': fields.contact_job_field(), }, ) + + +def activity_order_field(): + return Object( + properties={ + 'id': Keyword(), + 'created_on': Date(), + 'reference': Text(index=False), + 'primary_market': fields.country_field(), + 'uk_region': fields.area_field(), + 'contact': fields.contact_job_field(), + 'created_by': fields.contact_or_adviser_field(), + }, + ) diff --git a/datahub/search/company_activity/models.py b/datahub/search/company_activity/models.py index 01ede41c5..4b0fea0d3 100644 --- a/datahub/search/company_activity/models.py +++ b/datahub/search/company_activity/models.py @@ -4,11 +4,13 @@ from datahub.search.company_activity.dict_utils import ( activity_interaction_dict, activity_investment_dict, + activity_order_dict, activity_referral_dict, ) from datahub.search.company_activity.fields import ( activity_interaction_field, activity_investment_field, + activity_order_field, activity_referral_field, ) from datahub.search.models import BaseSearchModel @@ -26,6 +28,7 @@ class CompanyActivity(BaseSearchModel): interaction = activity_interaction_field() referral = activity_referral_field() investment = activity_investment_field() + order = activity_order_field() COMPUTED_MAPPINGS = {} @@ -34,6 +37,7 @@ class CompanyActivity(BaseSearchModel): 'referral': activity_referral_dict, 'company': dict_utils.company_dict, 'investment': activity_investment_dict, + 'order': activity_order_dict, } SEARCH_FIELDS = ( diff --git a/datahub/search/company_activity/signals.py b/datahub/search/company_activity/signals.py index 157a57161..2e44704ba 100644 --- a/datahub/search/company_activity/signals.py +++ b/datahub/search/company_activity/signals.py @@ -10,6 +10,7 @@ Interaction as DBInteraction, ) from datahub.investment.project.models import InvestmentProject as DBInvestmentProject +from datahub.omis.order.models import Order as DBOrder from datahub.search.company_activity import CompanyActivitySearchApp from datahub.search.company_activity.models import ( CompanyActivity as SearchCompanyActivity, @@ -59,6 +60,7 @@ def remove_interaction_from_opensearch(instance): sync_related_activity_to_opensearch), SignalReceiver(post_save, DBCompanyReferral, sync_related_activity_to_opensearch), + SignalReceiver(post_save, DBOrder, sync_related_activity_to_opensearch), SignalReceiver(post_save, DBInvestmentProject, sync_related_activity_to_opensearch), SignalReceiver(post_delete, DBCompanyActivity, diff --git a/datahub/search/company_activity/test/test_dict_utils.py b/datahub/search/company_activity/test/test_dict_utils.py index e4346c5df..cffaf6d63 100644 --- a/datahub/search/company_activity/test/test_dict_utils.py +++ b/datahub/search/company_activity/test/test_dict_utils.py @@ -3,6 +3,7 @@ from datahub.company_referral.test.factories import CompanyReferralFactory from datahub.interaction.test.factories import CompanyInteractionFactory from datahub.investment.project.test.factories import InvestmentProjectFactory +from datahub.omis.order.test.factories import OrderFactory from datahub.search.company_activity import dict_utils pytestmark = pytest.mark.django_db @@ -56,3 +57,17 @@ def test_activity_investment_dict(): assert result['investment_type']['id'] == str( investment.investment_type_id) assert result['estimated_land_date'] == investment.estimated_land_date + + +def test_activity_order_dict(): + obj = None + result = dict_utils.activity_order_dict(obj) + assert result is None + + order = OrderFactory() + result = dict_utils.activity_order_dict(order) + + assert result['id'] == str(order.id) + assert result['created_by']['id'] == str(order.created_by_id) + assert result['contact']['id'] == str(order.contact_id) + assert result['primary_market']['id'] == order.primary_market_id diff --git a/datahub/search/company_activity/test/test_models.py b/datahub/search/company_activity/test/test_models.py index 1b72161c5..41ee49e14 100644 --- a/datahub/search/company_activity/test/test_models.py +++ b/datahub/search/company_activity/test/test_models.py @@ -6,6 +6,7 @@ from datahub.company_activity.tests.factories import ( CompanyActivityInteractionFactory, CompanyActivityInvestmentProjectFactory, + CompanyActivityOmisOrderFactory, CompanyActivityReferralFactory, ) from datahub.search.company_activity import CompanyActivitySearchApp @@ -23,6 +24,7 @@ def test_company_activity_referral_to_dict(): assert result == { 'interaction': company_activity.interaction, 'investment': company_activity.investment, + 'order': company_activity.order, 'referral': { 'id': str(company_activity.referral_id), 'completed_on': company_activity.referral.completed_on, @@ -102,7 +104,8 @@ def test_company_activity_interaction_to_dict(): 'last_name': contact.last_name, } for contact in sorted( - company_activity.interaction.contacts.all(), key=attrgetter('id'), + company_activity.interaction.contacts.all(), + key=attrgetter('id'), ) ], 'communication_channel': { @@ -116,6 +119,7 @@ def test_company_activity_interaction_to_dict(): }, 'investment': company_activity.investment, 'referral': company_activity.referral, + 'order': company_activity.order, 'company': ( { 'id': str(company_activity.company_id), @@ -150,6 +154,7 @@ def test_company_activity_investment_to_dict(): assert result == { 'interaction': company_activity.interaction, + 'order': company_activity.order, 'investment': { 'id': str(company_activity.investment.id), 'name': company_activity.investment.name, @@ -168,7 +173,6 @@ def test_company_activity_investment_to_dict(): 'last_name': company_activity.investment.created_by.last_name, 'name': company_activity.investment.created_by.name, }, - 'client_contacts': client_contacts, }, 'referral': company_activity.referral, @@ -188,11 +192,62 @@ def test_company_activity_investment_to_dict(): } +def test_company_activity_order_to_dict(): + """Test converting a CompanyActivity with an order to a dict.""" + company_activity = CompanyActivityOmisOrderFactory.build() + + result = CompanyActivity.db_object_to_dict(company_activity) + + assert result == { + 'interaction': company_activity.interaction, + 'investment': company_activity.investment, + 'referral': company_activity.referral, + 'company': ( + { + 'id': str(company_activity.company_id), + 'name': company_activity.company.name, + 'trading_names': company_activity.company.trading_names, + } + if company_activity.company + else None + ), + 'order': { + 'reference': company_activity.order.reference, + 'uk_region': { + 'id': str(company_activity.order.uk_region.id), + 'name': company_activity.order.uk_region.name, + }, + 'created_on': company_activity.order.created_on, + 'contact': { + 'name': company_activity.order.contact.name, + 'last_name': company_activity.order.contact.last_name, + 'id': str(company_activity.order.contact.id), + 'first_name': company_activity.order.contact.first_name, + 'job_title': company_activity.order.contact.job_title, + }, + 'id': str(company_activity.order.id), + 'created_by': { + 'name': company_activity.order.created_by.name, + 'last_name': company_activity.order.created_by.last_name, + 'id': str(company_activity.order.created_by.id), + 'first_name': company_activity.order.created_by.first_name, + }, + 'primary_market': { + 'name': company_activity.order.primary_market.name, + 'id': str(company_activity.order.primary_market.id), + }, + }, + 'activity_source': DBCompanyActivity.ActivitySource.order, + 'id': company_activity.pk, + '_document_type': CompanyActivitySearchApp.name, + 'date': company_activity.date, + } + + def test_interactions_to_documents(): """Test converting 2 CompanyActivity's to OpenSearch documents.""" company_activities = CompanyActivityReferralFactory.build_batch(2) result = CompanyActivity.db_objects_to_documents(company_activities) - assert {item['_id'] for item in result} == { - item.pk for item in company_activities} + assert {item['_id'] for item in result} == {item.pk for item in company_activities} diff --git a/datahub/search/company_activity/test/test_signals.py b/datahub/search/company_activity/test/test_signals.py index c92048ab8..51490ffb7 100644 --- a/datahub/search/company_activity/test/test_signals.py +++ b/datahub/search/company_activity/test/test_signals.py @@ -17,6 +17,7 @@ InteractionDITParticipantFactory, ) from datahub.investment.project.test.factories import InvestmentProjectFactory +from datahub.omis.order.test.factories import OrderFactory from datahub.search.company_activity.apps import CompanyActivitySearchApp pytestmark = pytest.mark.django_db @@ -209,3 +210,34 @@ def test_company_activity_syncs_investment_fields_when_changed(opensearch_with_s assert actual_investment['estimated_land_date'] == str( new_estimated_land_date) assert actual_investment['created_by']['id'] == str(new_created_by.id) + + +def test_company_activity_syncs_order_fields_when_changed(opensearch_with_signals): + """Test that company_activities are synced to OpenSearch if their omis order updates.""" + order = OrderFactory() + company_activity = DBCompanyActivity.objects.get(order_id=order.id) + opensearch_with_signals.indices.refresh() + + doc = opensearch_with_signals.get( + index=CompanyActivitySearchApp.search_model.get_read_alias(), + id=company_activity.pk, + ) + + assert doc['_source']['order']['contact']['id'] == str(order.contact_id) + assert doc['_source']['order']['created_by']['id'] == str(order.created_by_id) + new_contact = ContactFactory() + order.contact = new_contact + + new_created_by = AdviserFactory() + order.created_by = new_created_by + order.save() + + opensearch_with_signals.indices.refresh() + + updated_doc = opensearch_with_signals.get( + index=CompanyActivitySearchApp.search_model.get_read_alias(), + id=company_activity.pk, + ) + actual_order = updated_doc['_source']['order'] + assert actual_order['contact']['id'] == str(new_contact.id) + assert actual_order['created_by']['id'] == str(new_created_by.id) diff --git a/datahub/search/company_activity/test/test_views.py b/datahub/search/company_activity/test/test_views.py index 12ef3bfbd..be9f25c7b 100644 --- a/datahub/search/company_activity/test/test_views.py +++ b/datahub/search/company_activity/test/test_views.py @@ -16,6 +16,7 @@ from datahub.company_activity.tests.factories import ( CompanyActivityInteractionFactory, CompanyActivityInvestmentProjectFactory, + CompanyActivityOmisOrderFactory, CompanyActivityReferralFactory, ) from datahub.core.test_utils import ( @@ -56,6 +57,9 @@ def company_activities(opensearch_with_collector): CompanyActivityInvestmentProjectFactory(company=company_1), CompanyActivityInvestmentProjectFactory(company=company_1), CompanyActivityInvestmentProjectFactory(company=company_2), + CompanyActivityOmisOrderFactory(company=company_1), + CompanyActivityOmisOrderFactory(company=company_1), + CompanyActivityOmisOrderFactory(company=company_2), ], ) @@ -117,7 +121,7 @@ def test_offset(self, company_activities): assert response.status_code == status.HTTP_200_OK response_data = response.json() - assert len(response_data['results']) == 9 + assert len(response_data['results']) == 12 def test_default_sort_by_date(self, opensearch_with_collector): """Tests default sorting of results by date (descending).""" diff --git a/datahub/search/sync_object.py b/datahub/search/sync_object.py index 576b9a413..4f2b1200d 100644 --- a/datahub/search/sync_object.py +++ b/datahub/search/sync_object.py @@ -1,5 +1,7 @@ from logging import getLogger +from django.core.exceptions import ObjectDoesNotExist + from datahub.core.models import BaseModel from datahub.core.queues.job_scheduler import job_scheduler from datahub.search.bulk_sync import sync_objects @@ -19,7 +21,14 @@ def sync_object(search_app, pk): search_model = search_app.search_model read_indices, write_index = search_model.get_read_and_write_indices() - obj = search_app.queryset.get(pk=pk) + try: + obj = search_app.queryset.get(pk=pk) + except ObjectDoesNotExist as e: + logger.exception( + f'An error occurred syncing a {search_app.name} object with id {pk}: {e} ' + f'Object {search_app.name} may have been deleted before being synced', + ) + return sync_objects( search_model, [obj], @@ -49,7 +58,14 @@ def sync_object_async(search_app, pk): retry_backoff=True, ) - obj = search_app.queryset.get(pk=pk) + try: + obj = search_app.queryset.get(pk=pk) + except ObjectDoesNotExist as e: + logger.exception( + f'An error occurred syncing a {search_app.name} object with id {pk}: {e} ' + f'Object {search_app.name} may have been deleted before being synced', + ) # object may be deleted + return if obj is not None and issubclass(type(obj), BaseModel): logger.info(f'Object {obj.pk} created on: {obj.created_on} ' diff --git a/datahub/search/test/test_sync_object.py b/datahub/search/test/test_sync_object.py index a716bc896..f77bd848e 100644 --- a/datahub/search/test/test_sync_object.py +++ b/datahub/search/test/test_sync_object.py @@ -1,6 +1,6 @@ import pytest -from datahub.search.sync_object import sync_object_async, sync_related_objects_async +from datahub.search.sync_object import sync_object, sync_object_async, sync_related_objects_async from datahub.search.test.search_support.models import RelatedModel, SimpleModel from datahub.search.test.search_support.relatedmodel import RelatedModelSearchApp from datahub.search.test.search_support.simplemodel import SimpleModelSearchApp @@ -31,3 +31,37 @@ def test_sync_related_objects_syncs_using_rq(opensearch): assert doc_exists(opensearch, RelatedModelSearchApp, relation_1.pk) assert doc_exists(opensearch, RelatedModelSearchApp, relation_2.pk) assert not doc_exists(opensearch, RelatedModelSearchApp, unrelated_obj.pk) + + +@pytest.mark.django_db +def test_sync_object_task_handles_obj_no_longer_in_db(opensearch, caplog): + """ + Test that the sync does not crash trying to sync a deleted object, there are signals which + can trigger the sync for deleted objects. + """ + caplog.set_level('ERROR') + obj = SimpleModel.objects.create() + obj_id = obj.id + + obj.delete() + sync_object(SimpleModelSearchApp, obj_id) + opensearch.indices.refresh() + + assert not doc_exists(opensearch, SimpleModelSearchApp, obj_id) + assert ( + f'An error occurred syncing a {SimpleModelSearchApp.name} object with id {obj_id}: ' + 'SimpleModel matching query does not exist. ' + f'Object {SimpleModelSearchApp.name} may have been deleted before being synced' + in caplog.text + ) + + sync_object_async(SimpleModelSearchApp, obj_id) + opensearch.indices.refresh() + + assert not doc_exists(opensearch, SimpleModelSearchApp, obj_id) + assert ( + f'An error occurred syncing a {SimpleModelSearchApp.name} object with id {obj_id}: ' + 'SimpleModel matching query does not exist. ' + f'Object {SimpleModelSearchApp.name} may have been deleted before being synced' + in caplog.text + )