From 553fc9fdeb6100a96d38c8651e82c0170863c5cf Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Mon, 17 Jun 2024 15:04:48 +0300 Subject: [PATCH 01/17] LMSM - Sort by expiry date in Checkout --- .../migrations/0004_alter_item_options.py | 17 +++++++++++++++++ src/etools/applications/last_mile/models.py | 2 +- .../applications/last_mile/tests/test_views.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/etools/applications/last_mile/migrations/0004_alter_item_options.py diff --git a/src/etools/applications/last_mile/migrations/0004_alter_item_options.py b/src/etools/applications/last_mile/migrations/0004_alter_item_options.py new file mode 100644 index 0000000000..44e73892b6 --- /dev/null +++ b/src/etools/applications/last_mile/migrations/0004_alter_item_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.19 on 2024-06-17 09:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('last_mile', '0003_alter_pointofinterest_p_code'), + ] + + operations = [ + migrations.AlterModelOptions( + name='item', + options={'base_manager_name': 'objects', 'ordering': ('expiry_date',)}, + ), + ] diff --git a/src/etools/applications/last_mile/models.py b/src/etools/applications/last_mile/models.py index 8dedf0276b..6de78e7f13 100644 --- a/src/etools/applications/last_mile/models.py +++ b/src/etools/applications/last_mile/models.py @@ -333,7 +333,7 @@ class Item(TimeStampedModel, models.Model): class Meta: base_manager_name = 'objects' - ordering = ("-id",) + ordering = ("expiry_date",) @cached_property def partner_organization(self): diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index 9b14486606..f2d7d74fc4 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -1,3 +1,4 @@ +import datetime from unittest import skip from unittest.mock import Mock, patch @@ -457,6 +458,17 @@ def test_mark_completed(self): self.assertEqual(self.outgoing.status, models.Transfer.COMPLETED) self.assertEqual(self.outgoing.checked_in_by, self.partner_staff) + def test_item_expiry_ordering(self): + item_1 = ItemFactory(transfer=self.outgoing, expiry_date=timezone.now() + datetime.timedelta(days=30)) + item_2 = ItemFactory(transfer=self.outgoing, expiry_date=timezone.now() + datetime.timedelta(days=20)) + item_3 = ItemFactory(transfer=self.outgoing, expiry_date=timezone.now() + datetime.timedelta(days=10)) + + url = reverse('last_mile:transfers-details', args=(self.poi_partner_1.pk, self.outgoing.pk,)) + response = self.forced_auth_req('get', url, user=self.partner_staff) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item_3.pk, item_2.pk, item_1.pk], [i['id'] for i in response.data['items']]) + class TestItemUpdateViewSet(BaseTenantTestCase): @classmethod From 777b9607caa78e02e9bb9b9a21eecc8e9dca17fc Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Mon, 17 Jun 2024 15:47:29 +0300 Subject: [PATCH 02/17] tests --- src/etools/applications/last_mile/tests/test_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index f2d7d74fc4..9379873118 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -252,10 +252,10 @@ def test_partial_checkin_with_short(self): self.assertEqual(short_transfer.transfer_subtype, models.Transfer.SHORT) self.assertEqual(short_transfer.destination_check_in_at, checkin_data['destination_check_in_at']) self.assertEqual(short_transfer.items.count(), 2) - loss_item_2 = short_transfer.items.first() + loss_item_2 = short_transfer.items.order_by('id').last() self.assertEqual(loss_item_2.quantity, 22) self.assertIn(self.incoming, loss_item_2.transfers_history.all()) - self.assertEqual(short_transfer.items.last().quantity, 30) + self.assertEqual(short_transfer.items.order_by('id').first().quantity, 30) self.assertEqual(short_transfer.origin_transfer, self.incoming) @override_settings(RUTF_MATERIALS=['1234']) From 1601ea57290aaa650668044a7ac3549766a8cf80 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Mon, 17 Jun 2024 15:48:59 +0300 Subject: [PATCH 03/17] LMSM - Make Location Mandatory during checkout --- .../applications/last_mile/serializers.py | 5 ++- .../last_mile/tests/test_views.py | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index 677c0bdbfb..fbdc563a93 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -429,7 +429,10 @@ def checkout_newtransfer_items(self, checkout_items): @transaction.atomic def create(self, validated_data): checkout_items = validated_data.pop('items') - if 'destination_point' in validated_data: + + if self.validated_data['transfer_type'] != models.Transfer.WASTAGE and not validated_data.get('destination_point'): + raise ValidationError(_('Destination location is mandatory at checkout.')) + elif 'destination_point' in validated_data: validated_data['destination_point_id'] = validated_data.pop('destination_point') self.instance = models.Transfer( diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index 9379873118..2876950de2 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -371,6 +371,24 @@ def test_checkout_validation(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('Some of the items to be checked are no longer valid', response.data['items']) + def test_checkout_location_validation(self): + item = ItemFactory(quantity=11, transfer=self.checked_in) + + checkout_data = { + "transfer_type": models.Transfer.DISTRIBUTION, + "comment": "", + "proof_file": self.attachment.pk, + "items": [ + {"id": item.pk, "quantity": 10} + ], + "origin_check_out_at": timezone.now() + } + url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Destination location is mandatory at checkout.', response.data) + def test_checkout_distribution(self): item_1 = ItemFactory(quantity=11, transfer=self.checked_in) item_2 = ItemFactory(quantity=22, transfer=self.checked_in) @@ -443,6 +461,29 @@ def test_checkout_wastage(self): self.assertEqual(self.checked_in.items.get(pk=item_1.pk).quantity, 2) self.assertEqual(self.checked_in.items.get(pk=item_2.pk).quantity, 22) + def test_checkout_wastage_without_location(self): + item_1 = ItemFactory(quantity=11, transfer=self.checked_in) + + checkout_data = { + "transfer_type": models.Transfer.WASTAGE, + "comment": "", + "proof_file": self.attachment.pk, + "items": [ + {"id": item_1.pk, "quantity": 9, "wastage_type": models.Item.EXPIRED}, + ], + "origin_check_out_at": timezone.now() + } + url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], models.Transfer.COMPLETED) + self.assertEqual(response.data['transfer_type'], models.Transfer.WASTAGE) + self.assertIn(response.data['proof_file'], self.attachment.file.path) + + wastage_transfer = models.Transfer.objects.get(pk=response.data['id']) + self.assertEqual(wastage_transfer.destination_point, None) + @skip('disabling feature for now') def test_mark_completed(self): self.assertEqual(self.outgoing.status, models.Transfer.PENDING) From 6e1fcc7796667fa31446ec926d08bdf7dc750bc4 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Thu, 20 Jun 2024 17:38:53 +0300 Subject: [PATCH 04/17] proof file required on all transfer types --- .../applications/last_mile/serializers.py | 7 +++++-- .../last_mile/tests/test_views.py | 20 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index fbdc563a93..9df6254e8e 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -233,7 +233,7 @@ class Meta: class TransferBaseSerializer(AttachmentSerializerMixin, serializers.ModelSerializer): - name = serializers.CharField(required=False, allow_blank=False, allow_null=False,) + name = serializers.CharField(required=False, allow_blank=False, allow_null=False) proof_file = AttachmentSingleFileField(required=True, allow_null=False) class Meta: @@ -430,7 +430,10 @@ def checkout_newtransfer_items(self, checkout_items): def create(self, validated_data): checkout_items = validated_data.pop('items') - if self.validated_data['transfer_type'] != models.Transfer.WASTAGE and not validated_data.get('destination_point'): + if not self.initial_data.get('proof_file'): + raise ValidationError(_('The proof file is required.')) + + if validated_data['transfer_type'] != models.Transfer.WASTAGE and not validated_data.get('destination_point'): raise ValidationError(_('Destination location is mandatory at checkout.')) elif 'destination_point' in validated_data: validated_data['destination_point_id'] = validated_data.pop('destination_point') diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index 2876950de2..39dc6ad677 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -152,7 +152,8 @@ def setUpTestData(cls): status=models.Transfer.COMPLETED, origin_point=cls.poi_partner_1 ) - cls.attachment = AttachmentFactory(file=SimpleUploadedFile('proof_file.pdf', b'Proof File')) + cls.attachment = AttachmentFactory( + file=SimpleUploadedFile('proof_file.pdf', b'Proof File'), code='proof_of_transfer') cls.material = MaterialFactory(number='1234') def test_incoming(self): @@ -484,6 +485,23 @@ def test_checkout_wastage_without_location(self): wastage_transfer = models.Transfer.objects.get(pk=response.data['id']) self.assertEqual(wastage_transfer.destination_point, None) + def test_checkout_without_proof_file(self): + item_1 = ItemFactory(quantity=11, transfer=self.checked_in) + + checkout_data = { + "transfer_type": models.Transfer.WASTAGE, + "comment": "", + "items": [ + {"id": item_1.pk, "quantity": 9, "wastage_type": models.Item.EXPIRED}, + ], + "origin_check_out_at": timezone.now() + } + url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('The proof file is required.', response.data) + @skip('disabling feature for now') def test_mark_completed(self): self.assertEqual(self.outgoing.status, models.Transfer.PENDING) From cf54fb3784452fada33be0729fc064f8d335e526 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Fri, 21 Jun 2024 11:00:55 +0300 Subject: [PATCH 05/17] [ch37446] LMSM: split item quantity in inventory --- .../applications/last_mile/serializers.py | 28 ++++ .../last_mile/tests/test_views.py | 133 ++++++++++++++---- src/etools/applications/last_mile/views.py | 10 ++ 3 files changed, 141 insertions(+), 30 deletions(-) diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index 9df6254e8e..78b5e4d6a7 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -209,6 +209,34 @@ class Meta(ItemBaseSerializer.Meta): fields = ItemBaseSerializer.Meta.fields + ('wastage_type',) +class ItemSplitSerializer(serializers.ModelSerializer): + quantities = serializers.ListSerializer(child=serializers.IntegerField(allow_null=False, required=True)) + + class Meta: + model = models.Item + fields = ('quantities',) + + def validate_quantities(self, value): + if len(value) != 2 or self.instance.quantity != sum(value): + raise ValidationError(_('Incorrect split values.')) + return value + + def save(self, **kwargs): + _item = models.Item( + transfer=self.instance.transfer, + material=self.instance.material, + quantity=self.validated_data['quantities'].pop(), + **model_to_dict( + self.instance, + exclude=['id', 'created', 'modified', 'transfer', 'material', 'transfers_history', 'quantity']) + ) + _item.save() + _item.transfers_history.add(self.instance.transfer) + + self.instance.quantity = self.validated_data['quantities'].pop() + self.instance.save(update_fields=['quantity']) + + class TransferSerializer(serializers.ModelSerializer): origin_point = PointOfInterestLightSerializer(read_only=True) destination_point = PointOfInterestLightSerializer(read_only=True) diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index 39dc6ad677..155f112f25 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -44,6 +44,8 @@ def test_api_poi_types_list(self): class TestPointOfInterestView(BaseTenantTestCase): + fixtures = ('poi_type.json',) + @classmethod def setUpTestData(cls): call_command("update_notifications") @@ -52,9 +54,10 @@ def setUpTestData(cls): realms__data=['IP LM Editor'], profile__organization=cls.partner.organization, ) - cls.poi_partner = PointOfInterestFactory(partner_organizations=[cls.partner], private=True) + # poi_type_id=4 -> school + cls.poi_partner = PointOfInterestFactory(partner_organizations=[cls.partner], private=True, poi_type_id=4) - def test_api_poi_list(self): + def test_poi_list(self): url = reverse("last_mile:pois-list") PointOfInterestFactory(private=True) @@ -64,7 +67,25 @@ def test_api_poi_list(self): self.assertEqual(len(response.data['results']), 1) self.assertEqual(self.poi_partner.pk, response.data['results'][0]['id']) - def test_api_item_list(self): + def test_poi_list_type_filter(self): + url = reverse("last_mile:pois-list") + + warehouse = PointOfInterestFactory(partner_organizations=[self.partner], private=True, poi_type_id=1) # warehouse + PointOfInterestFactory(partner_organizations=[self.partner], private=True, poi_type_id=2) # distribution_point + PointOfInterestFactory(partner_organizations=[self.partner], private=True, poi_type_id=3) # hospital + + response = self.forced_auth_req('get', url, user=self.partner_staff) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 4) + + response = self.forced_auth_req('get', url, user=self.partner_staff, data={"poi_type": 1}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(warehouse.pk, response.data['results'][0]['id']) + + def test_item_list(self): url = reverse('last_mile:inventory-item-list', args=(self.poi_partner.pk,)) transfer = TransferFactory( status=models.Transfer.COMPLETED, destination_point=self.poi_partner, partner_organization=self.partner) @@ -123,6 +144,8 @@ def test_api_item_list(self): class TestTransferView(BaseTenantTestCase): + fixtures = ('poi_type.json',) + @classmethod def setUpTestData(cls): cls.partner = PartnerFactory(organization=OrganizationFactory(name='Partner')) @@ -130,34 +153,34 @@ def setUpTestData(cls): realms__data=['IP LM Editor'], profile__organization=cls.partner.organization, ) - cls.poi_partner_1 = PointOfInterestFactory(partner_organizations=[cls.partner], private=True) - cls.poi_partner_2 = PointOfInterestFactory(partner_organizations=[cls.partner], private=True) - cls.poi_partner_3 = PointOfInterestFactory(partner_organizations=[cls.partner], private=True) + cls.warehouse = PointOfInterestFactory(partner_organizations=[cls.partner], private=True, poi_type_id=1) + cls.distribution_point = PointOfInterestFactory(partner_organizations=[cls.partner], private=True, poi_type_id=2) + cls.hospital = PointOfInterestFactory(partner_organizations=[cls.partner], private=True, poi_type_id=3) cls.incoming = TransferFactory( partner_organization=cls.partner, - destination_point=cls.poi_partner_1 + destination_point=cls.warehouse ) cls.checked_in = TransferFactory( partner_organization=cls.partner, status=models.Transfer.COMPLETED, - destination_point=cls.poi_partner_1 + destination_point=cls.warehouse ) cls.outgoing = TransferFactory( partner_organization=cls.partner, - origin_point=cls.poi_partner_1, + origin_point=cls.warehouse, transfer_type=models.Transfer.DISTRIBUTION ) cls.completed = TransferFactory( partner_organization=cls.partner, status=models.Transfer.COMPLETED, - origin_point=cls.poi_partner_1 + origin_point=cls.warehouse ) cls.attachment = AttachmentFactory( file=SimpleUploadedFile('proof_file.pdf', b'Proof File'), code='proof_of_transfer') cls.material = MaterialFactory(number='1234') def test_incoming(self): - url = reverse("last_mile:transfers-incoming", args=(self.poi_partner_1.pk,)) + url = reverse("last_mile:transfers-incoming", args=(self.warehouse.pk,)) response = self.forced_auth_req('get', url, user=self.partner_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -165,7 +188,7 @@ def test_incoming(self): self.assertEqual(self.incoming.pk, response.data['results'][0]['id']) def test_checked_in(self): - url = reverse('last_mile:transfers-checked-in', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-checked-in', args=(self.warehouse.pk,)) response = self.forced_auth_req('get', url, user=self.partner_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -173,7 +196,7 @@ def test_checked_in(self): self.assertEqual(self.checked_in.pk, response.data['results'][0]['id']) def test_outgoing(self): - url = reverse('last_mile:transfers-outgoing', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-outgoing', args=(self.warehouse.pk,)) response = self.forced_auth_req('get', url, user=self.partner_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -181,7 +204,7 @@ def test_outgoing(self): self.assertEqual(self.outgoing.pk, response.data['results'][0]['id']) def test_completed(self): - url = reverse('last_mile:transfers-completed', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-completed', args=(self.warehouse.pk,)) response = self.forced_auth_req('get', url, user=self.partner_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -205,7 +228,7 @@ def test_full_checkin(self): ], "destination_check_in_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-in', args=(self.poi_partner_1.pk, self.incoming.pk)) + url = reverse('last_mile:transfers-new-check-in', args=(self.warehouse.pk, self.incoming.pk)) response = self.forced_auth_req('patch', url, user=self.partner_staff, data=checkin_data) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -236,7 +259,7 @@ def test_partial_checkin_with_short(self): ], "destination_check_in_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-in', args=(self.poi_partner_1.pk, self.incoming.pk)) + url = reverse('last_mile:transfers-new-check-in', args=(self.warehouse.pk, self.incoming.pk)) response = self.forced_auth_req('patch', url, user=self.partner_staff, data=checkin_data) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -276,7 +299,7 @@ def test_partial_checkin_with_short_surplus(self): ], "destination_check_in_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-in', args=(self.poi_partner_1.pk, self.incoming.pk)) + url = reverse('last_mile:transfers-new-check-in', args=(self.warehouse.pk, self.incoming.pk)) response = self.forced_auth_req('patch', url, user=self.partner_staff, data=checkin_data) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -324,7 +347,7 @@ def test_partial_checkin_RUFT_material(self): ], "destination_check_in_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-in', args=(self.poi_partner_1.pk, self.incoming.pk)) + url = reverse('last_mile:transfers-new-check-in', args=(self.warehouse.pk, self.incoming.pk)) response = self.forced_auth_req('patch', url, user=self.partner_staff, data=checkin_data) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -366,13 +389,13 @@ def test_checkout_validation(self): ], "origin_check_out_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('Some of the items to be checked are no longer valid', response.data['items']) - def test_checkout_location_validation(self): + def test_checkout_distribution_location_validation(self): item = ItemFactory(quantity=11, transfer=self.checked_in) checkout_data = { @@ -384,7 +407,25 @@ def test_checkout_location_validation(self): ], "origin_check_out_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) + response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Destination location is mandatory at checkout.', response.data) + + def test_checkout_delivery_location_validation(self): + item = ItemFactory(quantity=11, transfer=self.checked_in) + + checkout_data = { + "transfer_type": models.Transfer.DELIVERY, + "comment": "", + "proof_file": self.attachment.pk, + "items": [ + {"id": item.pk, "quantity": 10} + ], + "origin_check_out_at": timezone.now() + } + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -394,11 +435,10 @@ def test_checkout_distribution(self): item_1 = ItemFactory(quantity=11, transfer=self.checked_in) item_2 = ItemFactory(quantity=22, transfer=self.checked_in) item_3 = ItemFactory(quantity=33, transfer=self.checked_in) - destination = PointOfInterestFactory() checkout_data = { "transfer_type": models.Transfer.DISTRIBUTION, - "destination_point": destination.pk, + "destination_point": self.hospital.pk, "comment": "", "proof_file": self.attachment.pk, "items": [ @@ -407,7 +447,7 @@ def test_checkout_distribution(self): ], "origin_check_out_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -416,7 +456,7 @@ def test_checkout_distribution(self): self.assertIn(response.data['proof_file'], self.attachment.file.path) checkout_transfer = models.Transfer.objects.get(pk=response.data['id']) - self.assertEqual(checkout_transfer.destination_point, destination) + self.assertEqual(checkout_transfer.destination_point, self.hospital) self.assertEqual(checkout_transfer.items.count(), len(checkout_data['items'])) self.assertEqual(checkout_transfer.items.get(pk=item_1.pk).quantity, 11) @@ -444,7 +484,7 @@ def test_checkout_wastage(self): ], "origin_check_out_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -474,7 +514,7 @@ def test_checkout_wastage_without_location(self): ], "origin_check_out_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -496,7 +536,7 @@ def test_checkout_without_proof_file(self): ], "origin_check_out_at": timezone.now() } - url = reverse('last_mile:transfers-new-check-out', args=(self.poi_partner_1.pk,)) + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -508,7 +548,7 @@ def test_mark_completed(self): self.assertEqual(self.outgoing.transfer_type, models.Transfer.DISTRIBUTION) - url = reverse('last_mile:transfers-mark-complete', args=(self.poi_partner_1.pk, self.outgoing.pk)) + url = reverse('last_mile:transfers-mark-complete', args=(self.warehouse.pk, self.outgoing.pk)) response = self.forced_auth_req('patch', url, user=self.partner_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -522,7 +562,7 @@ def test_item_expiry_ordering(self): item_2 = ItemFactory(transfer=self.outgoing, expiry_date=timezone.now() + datetime.timedelta(days=20)) item_3 = ItemFactory(transfer=self.outgoing, expiry_date=timezone.now() + datetime.timedelta(days=10)) - url = reverse('last_mile:transfers-details', args=(self.poi_partner_1.pk, self.outgoing.pk,)) + url = reverse('last_mile:transfers-details', args=(self.warehouse.pk, self.outgoing.pk,)) response = self.forced_auth_req('get', url, user=self.partner_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -621,3 +661,36 @@ def test_patch_wrong_qty(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('The calculated quantity is incorrect.', response.data['non_field_errors'][0]) + + def test_post_split(self): + item = ItemFactory(transfer=self.transfer, material=self.material, quantity=100) + self.assertEqual(self.transfer.items.count(), 1) + url = reverse('last_mile:item-update-split', args=(item.pk,)) + data = { + 'quantities': [76, 24] + } + response = self.forced_auth_req('post', url, user=self.partner_staff, data=data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.transfer.items.count(), 2) + item.refresh_from_db() + self.assertEqual(item.quantity, 76) + self.assertEqual(self.transfer.items.exclude(pk=item.pk).first().quantity, 24) + + def test_post_split_validation(self): + item = ItemFactory(transfer=self.transfer, material=self.material, quantity=100) + self.assertEqual(self.transfer.items.count(), 1) + url = reverse('last_mile:item-update-split', args=(item.pk,)) + data = { + 'quantities': [76, 25] + } + response = self.forced_auth_req('post', url, user=self.partner_staff, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Incorrect split values.', response.data['quantities'][0]) + + data['quantities'] = [1, 2, 97] + response = self.forced_auth_req('post', url, user=self.partner_staff, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Incorrect split values.', response.data['quantities'][0]) diff --git a/src/etools/applications/last_mile/views.py b/src/etools/applications/last_mile/views.py index a6bfd86cf3..56aed50dd3 100644 --- a/src/etools/applications/last_mile/views.py +++ b/src/etools/applications/last_mile/views.py @@ -319,6 +319,16 @@ def get_queryset(self): return super().get_queryset().none() return super().get_queryset().filter(transfer__partner_organization=partner) + @action(detail=True, methods=['post'], serializer_class=serializers.ItemSplitSerializer) + def split(self, request, **kwargs): + item = self.get_object() + + serializer = self.serializer_class(instance=item, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(status=status.HTTP_200_OK) + class PowerBIDataView(APIView): permission_classes = [IsIPLMEditor] From b08322417c93b916bb68c761c3d56c11c5658214 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Fri, 21 Jun 2024 11:58:28 +0300 Subject: [PATCH 06/17] [ch37445] LMSM: upload evidence files for wastage --- src/etools/applications/last_mile/admin.py | 13 +++++++- .../migrations/0005_transferevidence.py | 32 +++++++++++++++++++ src/etools/applications/last_mile/models.py | 26 +++++++++++++++ .../applications/last_mile/serializers.py | 17 ++++++++++ src/etools/applications/last_mile/views.py | 29 ++++++++++++++++- 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/etools/applications/last_mile/migrations/0005_transferevidence.py diff --git a/src/etools/applications/last_mile/admin.py b/src/etools/applications/last_mile/admin.py index b0c9bdc0d5..7d3bb4f06c 100644 --- a/src/etools/applications/last_mile/admin.py +++ b/src/etools/applications/last_mile/admin.py @@ -27,6 +27,11 @@ class WaybillTransferAttachmentInline(AttachmentSingleInline): code = 'waybill_file' +class TransferEvidenceAttachmentInline(AttachmentSingleInline): + verbose_name_plural = "Transfer Evidence File" + code = 'transfer_evidence' + + @admin.register(models.PointOfInterest) class PointOfInterestAdmin(XLSXImportMixin, admin.ModelAdmin): list_display = ('name', 'parent', 'poi_type', 'p_code') @@ -128,7 +133,7 @@ class TransferAdmin(AttachmentInlineAdminMixin, admin.ModelAdmin): search_fields = ('name', 'status') raw_id_fields = ('partner_organization', 'checked_in_by', 'checked_out_by', 'origin_point', 'destination_point', 'origin_transfer') - inlines = (ProofTransferAttachmentInline, WaybillTransferAttachmentInline, ItemInline) + inlines = (ProofTransferAttachmentInline, ItemInline) def get_queryset(self, request): qs = super(TransferAdmin, self).get_queryset(request)\ @@ -303,4 +308,10 @@ def get_or_create_transfer(filter_dict): ) +@admin.register(models.TransferEvidence) +class TransferEvidenceAdmin(AttachmentInlineAdminMixin, admin.ModelAdmin): + raw_id_fields = ('transfer', 'user') + inlines = [TransferEvidenceAttachmentInline] + + admin.site.register(models.PointOfInterestType) diff --git a/src/etools/applications/last_mile/migrations/0005_transferevidence.py b/src/etools/applications/last_mile/migrations/0005_transferevidence.py new file mode 100644 index 0000000000..debd3af51d --- /dev/null +++ b/src/etools/applications/last_mile/migrations/0005_transferevidence.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.19 on 2024-06-21 08:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('last_mile', '0004_alter_item_options'), + ] + + operations = [ + migrations.CreateModel( + name='TransferEvidence', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('comment', models.TextField(blank=True, null=True)), + ('transfer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_evidences', to='last_mile.transfer')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_evidences', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-created',), + }, + ), + ] diff --git a/src/etools/applications/last_mile/models.py b/src/etools/applications/last_mile/models.py index 6de78e7f13..5cbdf01e1b 100644 --- a/src/etools/applications/last_mile/models.py +++ b/src/etools/applications/last_mile/models.py @@ -188,6 +188,32 @@ def __str__(self): return f'{self.id} {self.partner_organization.name}: {self.name if self.name else self.unicef_release_order}' +class TransferEvidence(TimeStampedModel, models.Model): + comment = models.TextField(null=True, blank=True) + + evidence_file = CodedGenericRelation( + Attachment, + verbose_name=_('Transfer Evidence File'), + code='transfer_evidence', + ) + transfer = models.ForeignKey( + Transfer, + on_delete=models.CASCADE, + related_name='transfer_evidences' + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='transfer_evidences' + ) + + class Meta: + ordering = ("-created",) + + def __str__(self): + return f'{self.transfer.id} {self.transfer.transfer_type} / {self.transfer.partner_organization.name}' + + class Material(TimeStampedModel, models.Model): UOM = ( ("BAG", _("BAG")), diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index 78b5e4d6a7..a28b04a1a3 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -484,3 +484,20 @@ def create(self, validated_data): notify_wastage_transfer.delay(connection.schema_name, self.instance.pk) return self.instance + + +class TransferEvidenceSerializer(AttachmentSerializerMixin, serializers.ModelSerializer): + comment = serializers.CharField(required=True, allow_blank=False, allow_null=False) + evidence_file = AttachmentSingleFileField(required=True, allow_null=False) + + class Meta: + model = models.TransferEvidence + fields = ('comment', 'evidence_file') + + +class TransferEvidenceListSerializer(TransferEvidenceSerializer): + user = MinimalUserSerializer() + + class Meta(TransferEvidenceSerializer.Meta): + model = models.TransferEvidence + fields = TransferEvidenceSerializer.Meta.fields + ('id', 'user', 'created') diff --git a/src/etools/applications/last_mile/views.py b/src/etools/applications/last_mile/views.py index 56aed50dd3..2ad719f744 100644 --- a/src/etools/applications/last_mile/views.py +++ b/src/etools/applications/last_mile/views.py @@ -5,12 +5,13 @@ from django.db.models import CharField, OuterRef, Prefetch, Q, Subquery from django.shortcuts import get_object_or_404 from django.utils.functional import cached_property +from django.utils.translation import gettext as _ import requests from django_filters.rest_framework import DjangoFilterBackend from rest_framework import mixins, status from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.filters import SearchFilter from rest_framework.generics import ListAPIView from rest_framework.response import Response @@ -306,6 +307,32 @@ def new_check_out(self, request, **kwargs): return Response(serializers.TransferSerializer(serializer.instance).data, status=status.HTTP_200_OK) + @action(detail=True, methods=['post'], url_path='upload-evidence', + serializer_class=serializers.TransferEvidenceSerializer) + def upload_evidence(self, request, **kwargs): + transfer = self.get_object() + if transfer.transfer_type != models.Transfer.WASTAGE: + raise ValidationError(_('Evidence files are only for wastage transfers.')) + + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(transfer=transfer, user=request.user) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=['get'], serializer_class=serializers.TransferEvidenceListSerializer) + def evidence(self, request, **kwargs): + transfer = self.get_object() + if transfer.transfer_type != models.Transfer.WASTAGE: + raise ValidationError(_('Evidence files are only for wastage transfers.')) + qs = transfer.transfer_evidences.all() + page = self.paginate_queryset(qs) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + return Response(self.serializer_class(qs, many=True).data) + class ItemUpdateViewSet(mixins.UpdateModelMixin, mixins.RetrieveModelMixin, GenericViewSet): permission_classes = [IsIPLMEditor] From 02b9de8837551539def64a32ef5ad2a00e8a75cc Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Fri, 21 Jun 2024 12:04:06 +0300 Subject: [PATCH 07/17] [ch36303] LMSM: ip-to-ip-handover --- .../0006_alter_transfer_transfer_type.py | 18 ++++ src/etools/applications/last_mile/models.py | 2 + .../applications/last_mile/serializers.py | 23 ++++- .../last_mile/tests/test_views.py | 84 ++++++++++++++++++- src/etools/applications/last_mile/urls.py | 1 + src/etools/applications/last_mile/views.py | 14 ++++ 6 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py diff --git a/src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py b/src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py new file mode 100644 index 0000000000..ea91d30239 --- /dev/null +++ b/src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2024-06-21 08:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('last_mile', '0005_transferevidence'), + ] + + operations = [ + migrations.AlterField( + model_name='transfer', + name='transfer_type', + field=models.CharField(blank=True, choices=[('DELIVERY', 'Delivery'), ('DISTRIBUTION', 'Distribution'), ('HANDOVER', 'Handover'), ('WASTAGE', 'Wastage')], max_length=30, null=True), + ), + ] diff --git a/src/etools/applications/last_mile/models.py b/src/etools/applications/last_mile/models.py index 5cbdf01e1b..8ef86c4492 100644 --- a/src/etools/applications/last_mile/models.py +++ b/src/etools/applications/last_mile/models.py @@ -98,6 +98,7 @@ class Transfer(TimeStampedModel, models.Model): DELIVERY = 'DELIVERY' DISTRIBUTION = 'DISTRIBUTION' + HANDOVER = 'HANDOVER' WASTAGE = 'WASTAGE' SHORT = 'SHORT' @@ -110,6 +111,7 @@ class Transfer(TimeStampedModel, models.Model): TRANSFER_TYPE = ( (DELIVERY, _('Delivery')), (DISTRIBUTION, _('Distribution')), + (HANDOVER, _('Handover')), (WASTAGE, _('Wastage')) ) TRANSFER_SUBTYPE = ( diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index a28b04a1a3..0c83e15a4a 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -12,6 +12,7 @@ from etools.applications.last_mile import models from etools.applications.last_mile.models import PartnerMaterial from etools.applications.last_mile.tasks import notify_wastage_transfer +from etools.applications.partners.models import Agreement, PartnerOrganization from etools.applications.partners.serializers.partner_organization_v2 import MinimalPartnerOrganizationListSerializer from etools.applications.users.serializers import MinimalUserSerializer @@ -389,13 +390,21 @@ class TransferCheckOutSerializer(TransferBaseSerializer): origin_check_out_at = serializers.DateTimeField(required=True) destination_point = serializers.IntegerField(required=False) + partner_id = serializers.IntegerField(required=False, allow_null=False) class Meta(TransferBaseSerializer.Meta): model = models.Transfer fields = TransferBaseSerializer.Meta.fields + ( - 'transfer_type', 'items', 'origin_check_out_at', 'destination_point' + 'transfer_type', 'items', 'origin_check_out_at', 'destination_point', 'partner_id' ) + def validate_partner_id(self, value): + if value: + if not PartnerOrganization.objects.filter(agreements__status=Agreement.SIGNED, pk=value).exists() or \ + self.context['request'].user.partner.pk == value: + raise ValidationError(_('The provided partner is not eligible for a handover.')) + return value + def validate_items(self, value): # Make sure that all the items belong to this partner and are in the inventory of this location total_items_count = len(value) @@ -461,13 +470,21 @@ def create(self, validated_data): if not self.initial_data.get('proof_file'): raise ValidationError(_('The proof file is required.')) - if validated_data['transfer_type'] != models.Transfer.WASTAGE and not validated_data.get('destination_point'): + if validated_data['transfer_type'] not in [models.Transfer.WASTAGE, models.Transfer.HANDOVER] \ + and not validated_data.get('destination_point'): raise ValidationError(_('Destination location is mandatory at checkout.')) elif 'destination_point' in validated_data: validated_data['destination_point_id'] = validated_data.pop('destination_point') + if self.validated_data['transfer_type'] == models.Transfer.HANDOVER: + partner_id = validated_data.pop('partner_id', None) + if not partner_id: + raise ValidationError(_('A Handover to a partner requires a partner id.')) + else: + partner_id = self.context['request'].user.profile.organization.partner.pk + self.instance = models.Transfer( - partner_organization=self.context['request'].user.profile.organization.partner, + partner_organization_id=partner_id, origin_point=self.context['location'], checked_out_by=self.context['request'].user, **validated_data) diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index 155f112f25..1e03785a52 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -20,7 +20,8 @@ TransferFactory, ) from etools.applications.organizations.tests.factories import OrganizationFactory -from etools.applications.partners.tests.factories import PartnerFactory +from etools.applications.partners.models import Agreement +from etools.applications.partners.tests.factories import AgreementFactory, PartnerFactory from etools.applications.users.tests.factories import UserFactory @@ -502,6 +503,68 @@ def test_checkout_wastage(self): self.assertEqual(self.checked_in.items.get(pk=item_1.pk).quantity, 2) self.assertEqual(self.checked_in.items.get(pk=item_2.pk).quantity, 22) + def test_checkout_handover(self): + item_1 = ItemFactory(quantity=11, transfer=self.checked_in) + item_2 = ItemFactory(quantity=22, transfer=self.checked_in) + destination = PointOfInterestFactory() + agreement = AgreementFactory() + checkout_data = { + "transfer_type": models.Transfer.HANDOVER, + "destination_point": destination.pk, + "comment": "", + "proof_file": self.attachment.pk, + "partner_id": agreement.partner.id, + "items": [ + {"id": item_1.pk, "quantity": 9}, + ], + "origin_check_out_at": timezone.now() + } + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) + response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], models.Transfer.PENDING) + self.assertEqual(response.data['transfer_type'], models.Transfer.HANDOVER) + self.assertIn(response.data['proof_file'], self.attachment.file.path) + + handover_transfer = models.Transfer.objects.get(pk=response.data['id']) + self.assertEqual(handover_transfer.partner_organization, agreement.partner) + self.assertEqual(handover_transfer.destination_point, destination) + self.assertEqual(handover_transfer.items.count(), len(checkout_data['items'])) + self.assertEqual(handover_transfer.items.first().quantity, 9) + + self.assertEqual(self.checked_in.items.count(), 2) + self.assertEqual(self.checked_in.items.get(pk=item_1.pk).quantity, 2) + self.assertEqual(self.checked_in.items.get(pk=item_2.pk).quantity, 22) + + def test_checkout_handover_partner_validation(self): + item_1 = ItemFactory(quantity=11, transfer=self.checked_in) + destination = PointOfInterestFactory() + agreement = AgreementFactory(status=Agreement.DRAFT) + checkout_data = { + "transfer_type": models.Transfer.HANDOVER, + "destination_point": destination.pk, + "comment": "", + "proof_file": self.attachment.pk, + "partner_id": agreement.partner.id, + "items": [ + {"id": item_1.pk, "quantity": 9}, + ], + "origin_check_out_at": timezone.now() + } + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) + response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('The provided partner is not eligible for a handover.', response.data['partner_id'][0]) + + checkout_data['partner_id'] = self.partner_staff.partner.id + url = reverse('last_mile:transfers-new-check-out', args=(self.warehouse.pk,)) + response = self.forced_auth_req('post', url, user=self.partner_staff, data=checkout_data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('The provided partner is not eligible for a handover.', response.data['partner_id'][0]) + def test_checkout_wastage_without_location(self): item_1 = ItemFactory(quantity=11, transfer=self.checked_in) @@ -568,6 +631,25 @@ def test_item_expiry_ordering(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual([item_3.pk, item_2.pk, item_1.pk], [i['id'] for i in response.data['items']]) + def test_upload_evidence(self): + url = reverse('last_mile:transfers-upload-evidence', args=(self.warehouse.pk, self.completed.pk)) + attachment = AttachmentFactory(file=SimpleUploadedFile('hello_world.txt', b'hello world!')) + data = {'evidence_file': attachment.id, 'comment': 'some comment'} + self.assertNotEqual(self.completed.transfer_type, self.completed.WASTAGE) + + response = self.forced_auth_req('post', url, user=self.partner_staff, data=data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Evidence files are only for wastage transfers.', response.data) + + self.completed.transfer_type = self.completed.WASTAGE + self.completed.save(update_fields=['transfer_type']) + self.assertEqual(self.completed.transfer_type, self.completed.WASTAGE) + self.assertEqual(self.completed.transfer_evidences.count(), 0) + + response = self.forced_auth_req('post', url, user=self.partner_staff, data=data) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(self.completed.transfer_evidences.count(), 1) + class TestItemUpdateViewSet(BaseTenantTestCase): @classmethod diff --git a/src/etools/applications/last_mile/urls.py b/src/etools/applications/last_mile/urls.py index 9b29193a43..1aa9331476 100644 --- a/src/etools/applications/last_mile/urls.py +++ b/src/etools/applications/last_mile/urls.py @@ -8,6 +8,7 @@ app_name = 'last_mile' root_api = routers.SimpleRouter() +root_api.register(r'partners', views.HandoverPartnerListViewSet, basename='partners') root_api.register(r'points-of-interest', views.PointOfInterestViewSet, basename='pois') root_api.register(r'poi-types', views.PointOfInterestTypeViewSet, basename='poi-types') root_api.register(r'items', views.ItemUpdateViewSet, basename='item-update') diff --git a/src/etools/applications/last_mile/views.py b/src/etools/applications/last_mile/views.py index 2ad719f744..d256d64406 100644 --- a/src/etools/applications/last_mile/views.py +++ b/src/etools/applications/last_mile/views.py @@ -23,6 +23,8 @@ from etools.applications.last_mile.filters import TransferFilter from etools.applications.last_mile.permissions import IsIPLMEditor from etools.applications.last_mile.tasks import notify_upload_waybill +from etools.applications.partners.models import Agreement, PartnerOrganization +from etools.applications.partners.serializers.partner_organization_v2 import MinimalPartnerOrganizationListSerializer from etools.applications.utils.pbi_auth import get_access_token, get_embed_token, get_embed_url, TokenRetrieveException @@ -74,6 +76,18 @@ def upload_waybill(self, request, pk=None): return Response(status=status.HTTP_204_NO_CONTENT) +class HandoverPartnerListViewSet(mixins.ListModelMixin, GenericViewSet): + serializer_class = MinimalPartnerOrganizationListSerializer + permission_classes = [IsIPLMEditor] + pagination_class = DynamicPageNumberPagination + + filter_backends = (SearchFilter,) + search_fields = ('name',) + + def get_queryset(self): + return PartnerOrganization.objects.filter(agreements__status=Agreement.SIGNED).values('id', 'name') + + class InventoryItemListView(POIQuerysetMixin, ListAPIView): permission_classes = [IsIPLMEditor] serializer_class = serializers.ItemSimpleListSerializer From 2c06d9c5374abdbae9c140d90aca3a702e919020 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Fri, 21 Jun 2024 13:22:16 +0300 Subject: [PATCH 08/17] squash migrations --- ...revidence.py => 0005_auto_20240621_0845.py} | 7 ++++++- .../0006_alter_transfer_transfer_type.py | 18 ------------------ 2 files changed, 6 insertions(+), 19 deletions(-) rename src/etools/applications/last_mile/migrations/{0005_transferevidence.py => 0005_auto_20240621_0845.py} (79%) delete mode 100644 src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py diff --git a/src/etools/applications/last_mile/migrations/0005_transferevidence.py b/src/etools/applications/last_mile/migrations/0005_auto_20240621_0845.py similarity index 79% rename from src/etools/applications/last_mile/migrations/0005_transferevidence.py rename to src/etools/applications/last_mile/migrations/0005_auto_20240621_0845.py index debd3af51d..bb1a1d6ffc 100644 --- a/src/etools/applications/last_mile/migrations/0005_transferevidence.py +++ b/src/etools/applications/last_mile/migrations/0005_auto_20240621_0845.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.19 on 2024-06-21 08:07 +# Generated by Django 3.2.19 on 2024-06-21 10:19 from django.conf import settings from django.db import migrations, models @@ -15,6 +15,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterField( + model_name='transfer', + name='transfer_type', + field=models.CharField(blank=True, choices=[('DELIVERY', 'Delivery'), ('DISTRIBUTION', 'Distribution'), ('HANDOVER', 'Handover'), ('WASTAGE', 'Wastage')], max_length=30, null=True), + ), migrations.CreateModel( name='TransferEvidence', fields=[ diff --git a/src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py b/src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py deleted file mode 100644 index ea91d30239..0000000000 --- a/src/etools/applications/last_mile/migrations/0006_alter_transfer_transfer_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.19 on 2024-06-21 08:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('last_mile', '0005_transferevidence'), - ] - - operations = [ - migrations.AlterField( - model_name='transfer', - name='transfer_type', - field=models.CharField(blank=True, choices=[('DELIVERY', 'Delivery'), ('DISTRIBUTION', 'Distribution'), ('HANDOVER', 'Handover'), ('WASTAGE', 'Wastage')], max_length=30, null=True), - ), - ] From b2233692b3f528e3bc23297bb356b374f7221ab9 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Thu, 13 Jun 2024 15:11:14 +0300 Subject: [PATCH 09/17] added missing audit migration; remove comment on migration check in runtests.sh --- runtests.sh | 4 ++-- .../migrations/0032_auto_20240613_1204.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 src/etools/applications/audit/migrations/0032_auto_20240613_1204.py diff --git a/runtests.sh b/runtests.sh index 53bf8c0faf..7d9ed1512c 100755 --- a/runtests.sh +++ b/runtests.sh @@ -9,8 +9,8 @@ if [[ $DJANGO_SETTINGS_MODULE = '' || $DJANGO_SETTINGS_MODULE = etools.config.se fi # Ensure there are no errors. -#python -W ignore manage.py check -#python -W ignore manage.py makemigrations --dry-run --check +python -W ignore manage.py check +python -W ignore manage.py makemigrations --dry-run --check # Ensure translations are up-to-date. cwd=$(pwd) diff --git a/src/etools/applications/audit/migrations/0032_auto_20240613_1204.py b/src/etools/applications/audit/migrations/0032_auto_20240613_1204.py new file mode 100644 index 0000000000..4a5a96e58d --- /dev/null +++ b/src/etools/applications/audit/migrations/0032_auto_20240613_1204.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.19 on 2024-06-13 12:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audit', '0031_auto_20240507_1059'), + ] + + operations = [ + migrations.AlterField( + model_name='engagement', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='End date of last reporting FACE'), + ), + migrations.AlterField( + model_name='engagement', + name='start_date', + field=models.DateField(blank=True, null=True, verbose_name='Start date of first reporting FACE'), + ), + ] From 2c64b695937077798ce62d83d53d81d2c96f823b Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Tue, 25 Jun 2024 15:05:14 +0300 Subject: [PATCH 10/17] [ch37533] LMSM bugs: block checkin of an already checked-in transfer; validate against 0 value on split; --- src/etools/applications/last_mile/serializers.py | 7 +++++-- src/etools/applications/last_mile/tests/test_views.py | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index 0c83e15a4a..5aa6855d11 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -218,7 +218,7 @@ class Meta: fields = ('quantities',) def validate_quantities(self, value): - if len(value) != 2 or self.instance.quantity != sum(value): + if len(value) != 2 or self.instance.quantity != sum(value) or not all(value): raise ValidationError(_('Incorrect split values.')) return value @@ -335,6 +335,9 @@ def get_short_surplus_items(orig_items_dict, checkin_items): def update(self, instance, validated_data): checkin_items = validated_data.pop('items') + if instance.status == models.Transfer.COMPLETED: + raise ValidationError(_('The transfer was already checked-in.')) + validated_data['status'] = models.Transfer.COMPLETED validated_data['checked_in_by'] = self.context.get('request').user validated_data["destination_point"] = self.context["location"] @@ -476,7 +479,7 @@ def create(self, validated_data): elif 'destination_point' in validated_data: validated_data['destination_point_id'] = validated_data.pop('destination_point') - if self.validated_data['transfer_type'] == models.Transfer.HANDOVER: + if validated_data['transfer_type'] == models.Transfer.HANDOVER: partner_id = validated_data.pop('partner_id', None) if not partner_id: raise ValidationError(_('A Handover to a partner requires a partner id.')) diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index 1e03785a52..d2aac2a254 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -244,6 +244,11 @@ def test_full_checkin(self): self.assertFalse(models.Transfer.objects.filter(transfer_type=models.Transfer.WASTAGE).exists()) + # test new checkin of an already checked-in transfer + response = self.forced_auth_req('patch', url, user=self.partner_staff, data=checkin_data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('The transfer was already checked-in.', response.data) + @override_settings(RUTF_MATERIALS=['1234']) def test_partial_checkin_with_short(self): item_1 = ItemFactory(quantity=11, transfer=self.incoming, material=self.material) From 099417322c789ffb9d3c14fe6b3d016787db651e Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Tue, 25 Jun 2024 18:16:10 +0300 Subject: [PATCH 11/17] fill transfer name when not provided --- .../applications/last_mile/serializers.py | 20 +++++++++++++++++-- .../last_mile/tests/test_views.py | 9 ++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index 5aa6855d11..c0998b4ebb 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -269,6 +269,19 @@ class Meta: model = models.Transfer fields = ('name', 'comment', 'proof_file') + @staticmethod + def get_transfer_name(validated_data, transfer_type=None): + prefix_mapping = { + "HANDOVER": "HO", + "WASTAGE": "W", + "DELIVERY": "DW", + "DISTRIBUTION": "DD" + + } + date = validated_data.get('origin_check_out_at') or validated_data.get('destination_check_in_at') or timezone.now() + transfer_type = validated_data.get("transfer_type") or transfer_type + return f'{prefix_mapping[transfer_type]} @ {date.strftime("%y-%m-%d")}-{int(date.timestamp()) % 100000}' + class TransferCheckinSerializer(TransferBaseSerializer): items = ItemBaseSerializer(many=True, required=False, allow_null=True) @@ -341,6 +354,8 @@ def update(self, instance, validated_data): validated_data['status'] = models.Transfer.COMPLETED validated_data['checked_in_by'] = self.context.get('request').user validated_data["destination_point"] = self.context["location"] + if 'name' not in validated_data: + validated_data['name'] = self.get_transfer_name(validated_data, instance.transfer_type) if self.partial: orig_items_dict = {obj.id: obj for obj in instance.items.all()} @@ -486,6 +501,9 @@ def create(self, validated_data): else: partner_id = self.context['request'].user.profile.organization.partner.pk + if 'name' not in validated_data: + validated_data['name'] = self.get_transfer_name(validated_data) + self.instance = models.Transfer( partner_organization_id=partner_id, origin_point=self.context['location'], @@ -494,8 +512,6 @@ def create(self, validated_data): if self.instance.transfer_type == models.Transfer.WASTAGE: self.instance.status = models.Transfer.COMPLETED - checkout_datetime = validated_data['origin_check_out_at'] or timezone.now() - self.instance.name = f'W @ {checkout_datetime.strftime("%y-%m-%d")}' self.instance.save() diff --git a/src/etools/applications/last_mile/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index d2aac2a254..f2b6afac82 100644 --- a/src/etools/applications/last_mile/tests/test_views.py +++ b/src/etools/applications/last_mile/tests/test_views.py @@ -159,7 +159,8 @@ def setUpTestData(cls): cls.hospital = PointOfInterestFactory(partner_organizations=[cls.partner], private=True, poi_type_id=3) cls.incoming = TransferFactory( partner_organization=cls.partner, - destination_point=cls.warehouse + destination_point=cls.warehouse, + transfer_type=models.Transfer.DELIVERY ) cls.checked_in = TransferFactory( partner_organization=cls.partner, @@ -256,7 +257,6 @@ def test_partial_checkin_with_short(self): item_3 = ItemFactory(quantity=33, transfer=self.incoming, material=self.material) checkin_data = { - "name": "checked in transfer", "comment": "", "proof_file": self.attachment.pk, "items": [ @@ -272,7 +272,8 @@ def test_partial_checkin_with_short(self): self.incoming.refresh_from_db() self.assertEqual(self.incoming.status, models.Transfer.COMPLETED) self.assertIn(response.data['proof_file'], self.attachment.file.path) - self.assertEqual(self.incoming.name, checkin_data['name']) + + self.assertIn(f'DW @ {checkin_data["destination_check_in_at"].strftime("%y-%m-%d")}', self.incoming.name) self.assertEqual(self.incoming.items.count(), len(response.data['items'])) self.assertEqual(self.incoming.items.get(pk=item_1.pk).quantity, 11) self.assertEqual(self.incoming.items.get(pk=item_3.pk).quantity, 3) @@ -507,6 +508,7 @@ def test_checkout_wastage(self): self.assertEqual(self.checked_in.items.count(), 2) self.assertEqual(self.checked_in.items.get(pk=item_1.pk).quantity, 2) self.assertEqual(self.checked_in.items.get(pk=item_2.pk).quantity, 22) + self.assertIn(f'W @ {checkout_data["origin_check_out_at"].strftime("%y-%m-%d")}', wastage_transfer.name) def test_checkout_handover(self): item_1 = ItemFactory(quantity=11, transfer=self.checked_in) @@ -541,6 +543,7 @@ def test_checkout_handover(self): self.assertEqual(self.checked_in.items.count(), 2) self.assertEqual(self.checked_in.items.get(pk=item_1.pk).quantity, 2) self.assertEqual(self.checked_in.items.get(pk=item_2.pk).quantity, 22) + self.assertIn(f'HO @ {checkout_data["origin_check_out_at"].strftime("%y-%m-%d")}', handover_transfer.name) def test_checkout_handover_partner_validation(self): item_1 = ItemFactory(quantity=11, transfer=self.checked_in) From 535457eb35d649189e43ab402de4114f92c7dbc6 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Tue, 25 Jun 2024 19:28:37 +0300 Subject: [PATCH 12/17] remove updating logic from Vision Ingest transfers/items and materials api --- .../applications/last_mile/views_ext.py | 53 ++++--------------- 1 file changed, 11 insertions(+), 42 deletions(-) diff --git a/src/etools/applications/last_mile/views_ext.py b/src/etools/applications/last_mile/views_ext.py index 25c42e55d8..3039549f9b 100644 --- a/src/etools/applications/last_mile/views_ext.py +++ b/src/etools/applications/last_mile/views_ext.py @@ -40,22 +40,17 @@ class VisionIngestMaterialsApiView(APIView): def post(self, request): set_country('somalia') materials_to_create = [] - materials_to_update = [] for material in request.data: model_dict = {} for k, v in material.items(): if k in self.mapping: model_dict[self.mapping[k]] = strip_tags(v) # strip text that has html tags try: - obj = models.Material.objects.get(number=model_dict['number']) - for field, value in model_dict.items(): - setattr(obj, field, value) - materials_to_update.append(obj) + models.Material.objects.get(number=model_dict['number']) except models.Material.DoesNotExist: materials_to_create.append(models.Material(**model_dict)) models.Material.objects.bulk_create(materials_to_create) - models.Material.objects.bulk_update(materials_to_update, fields=list(self.mapping.values())) return Response(status=status.HTTP_200_OK) @@ -176,16 +171,15 @@ class VisionIngestTransfersApiView(APIView): @staticmethod def get_transfer(transfer_dict): - for_create = True try: organization = Organization.objects.get(vendor_number=transfer_dict['vendor_number']) except Organization.DoesNotExist: logging.error(f"No organization found in etools for {transfer_dict['vendor_number']}") - return for_create, None + return None if not hasattr(organization, 'partner'): - logging.error(f'No partner in rwanda available for vendor_number {transfer_dict["vendor_number"]}') - return for_create, None + logging.error(f'No partner available for vendor_number {transfer_dict["vendor_number"]}') + return None origin_point = models.PointOfInterest.objects.get(pk=1) # Unicef Warehouse transfer_dict.pop('vendor_number') @@ -197,16 +191,13 @@ def get_transfer(transfer_dict): try: transfer_obj = models.Transfer.objects.get(unicef_release_order=transfer_dict['unicef_release_order']) - for_create = False - for field, value in transfer_dict.items(): - setattr(transfer_obj, field, value) - return for_create, transfer_obj + return transfer_obj except models.Transfer.DoesNotExist: - return for_create, models.Transfer(**transfer_dict) + return models.Transfer(**transfer_dict) @staticmethod def import_items(transfer_items): - items_to_create, items_to_update = [], [] + items_to_create = [] for unicef_ro, items in transfer_items.items(): try: transfer = models.Transfer.objects.get(unicef_release_order=unicef_ro) @@ -218,12 +209,6 @@ def import_items(transfer_items): try: material = models.Material.objects.get(number=item_dict['material']) item_dict['material'] = material - if item_dict['description'] != material.short_description: - models.PartnerMaterial.objects.update_or_create( - partner_organization=transfer.partner_organization, - material=material, - defaults={'description': item_dict['description']} - ) item_dict.pop('description') if item_dict['uom'] == material.original_uom: item_dict.pop('uom') @@ -231,25 +216,17 @@ def import_items(transfer_items): logging.error(f"No Material found in etools with # {item_dict['material']}") continue try: - item_obj = models.Item.objects.get( + models.Item.objects.get( transfer__unicef_release_order=unicef_ro, unicef_ro_item=item_dict['unicef_ro_item']) - for field, value in item_dict.items(): - setattr(item_obj, field, value) - items_to_update.append(item_obj) except models.Item.DoesNotExist: item_dict['transfer'] = transfer items_to_create.append(models.Item(**item_dict)) models.Item.objects.bulk_create(items_to_create) - models.Item.objects.bulk_update( - items_to_update, - fields=['unicef_ro_item', 'material', 'quantity', 'uom', 'batch_id', 'expiry_date', - 'purchase_order_item', 'amount_usd', 'other'] - ) def post(self, request): set_country('somalia') - transfers_to_create, transfers_to_update = [], [] + transfers_to_create = [] transfer_items = {} for row in request.data: # only consider LD events @@ -268,16 +245,12 @@ def post(self, request): else: item_dict[self.item_mapping[k]] = strip_tags(v) - created, transfer_obj = self.get_transfer(transfer_dict) + transfer_obj = self.get_transfer(transfer_dict) if not transfer_obj: continue - if created and transfer_obj.unicef_release_order not in \ - [o.unicef_release_order for o in transfers_to_create]: + if not transfer_obj.pk and transfer_obj.unicef_release_order not in [o.unicef_release_order for o in transfers_to_create]: transfers_to_create.append(transfer_obj) - elif not created and transfer_obj.unicef_release_order not in \ - [o.unicef_release_order for o in transfers_to_update]: - transfers_to_update.append(transfer_obj) if transfer_dict['unicef_release_order'] in transfer_items: transfer_items[transfer_dict['unicef_release_order']].append(item_dict) @@ -285,10 +258,6 @@ def post(self, request): transfer_items[transfer_dict['unicef_release_order']] = [item_dict] models.Transfer.objects.bulk_create(transfers_to_create) - models.Transfer.objects.bulk_update( - transfers_to_update, - fields=['purchase_order_id', 'pd_number', 'waybill_id', 'origin_check_out_at'] - ) self.import_items(transfer_items) return Response(status=status.HTTP_200_OK) From 79b71d78182901a1a6889d0211dcad04498ebf9c Mon Sep 17 00:00:00 2001 From: Robert Avram Date: Tue, 25 Jun 2024 15:35:40 -0400 Subject: [PATCH 13/17] Update serializers.py --- src/etools/applications/last_mile/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index c0998b4ebb..f3b30292dc 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -262,7 +262,7 @@ class Meta: class TransferBaseSerializer(AttachmentSerializerMixin, serializers.ModelSerializer): - name = serializers.CharField(required=False, allow_blank=False, allow_null=False) + name = serializers.CharField(required=False, allow_blank=True, allow_null=True) proof_file = AttachmentSingleFileField(required=True, allow_null=False) class Meta: @@ -354,7 +354,7 @@ def update(self, instance, validated_data): validated_data['status'] = models.Transfer.COMPLETED validated_data['checked_in_by'] = self.context.get('request').user validated_data["destination_point"] = self.context["location"] - if 'name' not in validated_data: + if not instance.name and 'name' not in validated_data: validated_data['name'] = self.get_transfer_name(validated_data, instance.transfer_type) if self.partial: From 2085c8742d667b40a229677e13f023580a782273 Mon Sep 17 00:00:00 2001 From: Robert Avram Date: Fri, 28 Jun 2024 22:02:46 -0400 Subject: [PATCH 14/17] Update serializers.py --- src/etools/applications/last_mile/serializers.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index f3b30292dc..b25aa30b10 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -354,7 +354,8 @@ def update(self, instance, validated_data): validated_data['status'] = models.Transfer.COMPLETED validated_data['checked_in_by'] = self.context.get('request').user validated_data["destination_point"] = self.context["location"] - if not instance.name and 'name' not in validated_data: + + if not instance.name and not validated_data.get('name'): validated_data['name'] = self.get_transfer_name(validated_data, instance.transfer_type) if self.partial: @@ -402,7 +403,7 @@ def update(self, instance, validated_data): class TransferCheckOutSerializer(TransferBaseSerializer): - name = serializers.CharField(required=False) + name = serializers.CharField(required=False, allow_blank=True, allow_null=True) transfer_type = serializers.ChoiceField(choices=models.Transfer.TRANSFER_TYPE, required=True) items = ItemCheckoutSerializer(many=True, required=True) @@ -491,7 +492,7 @@ def create(self, validated_data): if validated_data['transfer_type'] not in [models.Transfer.WASTAGE, models.Transfer.HANDOVER] \ and not validated_data.get('destination_point'): raise ValidationError(_('Destination location is mandatory at checkout.')) - elif 'destination_point' in validated_data: + elif validated_data.get('destination_point'): validated_data['destination_point_id'] = validated_data.pop('destination_point') if validated_data['transfer_type'] == models.Transfer.HANDOVER: @@ -501,7 +502,7 @@ def create(self, validated_data): else: partner_id = self.context['request'].user.profile.organization.partner.pk - if 'name' not in validated_data: + if not validated_data.get("name"): validated_data['name'] = self.get_transfer_name(validated_data) self.instance = models.Transfer( From 98d3e7276ad7bdd309e29e185f3888410a56dcc4 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Mon, 1 Jul 2024 10:28:46 +0300 Subject: [PATCH 15/17] remove item ordering in list view --- src/etools/applications/last_mile/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/etools/applications/last_mile/views.py b/src/etools/applications/last_mile/views.py index d256d64406..938506fd7e 100644 --- a/src/etools/applications/last_mile/views.py +++ b/src/etools/applications/last_mile/views.py @@ -111,8 +111,7 @@ def get_queryset(self): .filter(transfer__partner_organization=partner, transfer__status=models.Transfer.COMPLETED, transfer__destination_point=poi.pk)\ - .exclude(transfer__transfer_type=models.Transfer.WASTAGE)\ - .order_by('created', '-id') + .exclude(transfer__transfer_type=models.Transfer.WASTAGE) qs = qs.select_related('transfer', 'material', From 476fe8936c715ed9f9a4f1336b8320eac920c7c8 Mon Sep 17 00:00:00 2001 From: Robert Avram Date: Wed, 3 Jul 2024 10:50:41 -0700 Subject: [PATCH 16/17] Update __init__.py --- src/etools/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/etools/__init__.py b/src/etools/__init__.py index beecb47ccd..d1582947cb 100644 --- a/src/etools/__init__.py +++ b/src/etools/__init__.py @@ -1,2 +1,2 @@ -VERSION = __version__ = '11.4' +VERSION = __version__ = '11.5' NAME = 'eTools' From f1ef1cc73497669968771ed9c24f1b32ceb17f4b Mon Sep 17 00:00:00 2001 From: Robert Avram Date: Fri, 5 Jul 2024 13:02:28 -0400 Subject: [PATCH 17/17] Update admin.py --- src/etools/applications/last_mile/admin.py | 76 +++++++++++++++++++--- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/src/etools/applications/last_mile/admin.py b/src/etools/applications/last_mile/admin.py index 7d3bb4f06c..e67e8693af 100644 --- a/src/etools/applications/last_mile/admin.py +++ b/src/etools/applications/last_mile/admin.py @@ -4,6 +4,8 @@ from django.contrib.gis import forms from django.contrib.gis.geos import Point from django.db import transaction +from django.urls import reverse +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from unicef_attachments.admin import AttachmentSingleInline @@ -34,6 +36,7 @@ class TransferEvidenceAttachmentInline(AttachmentSingleInline): @admin.register(models.PointOfInterest) class PointOfInterestAdmin(XLSXImportMixin, admin.ModelAdmin): + readonly_fields = ('partner_names',) list_display = ('name', 'parent', 'poi_type', 'p_code') list_select_related = ('parent',) list_filter = ('private', 'is_active') @@ -53,6 +56,17 @@ class PointOfInterestAdmin(XLSXImportMixin, admin.ModelAdmin): 'P CODE': 'p_code' } + def partner_names(self, obj): + p_names = [] + for p in obj.partner_organizations.all(): + print(p) + url = reverse('admin:partners_partnerorganization_change', args=[p.id]) + html = format_html('{}', url, p.name) + p_names.append(html) + return format_html('
'.join(p_names)) + partner_names.short_description = 'Partner Names' + partner_names.admin_order_field = 'name' + def has_import_permission(self, request): return is_user_in_groups(request.user, ['Country Office Administrator']) @@ -175,6 +189,16 @@ class ItemAdmin(XLSXImportMixin, admin.ModelAdmin): list_display = ('batch_id', 'material', 'wastage_type', 'transfer') raw_id_fields = ('transfer', 'transfers_history', 'material') list_filter = ('wastage_type', 'hidden') + readonly_fields = ('destination_point_name',) + + def destination_point_name(self, obj): + if obj.transfer and obj.transfer.destination_point: + url = reverse('admin:last_mile_pointofinterest_change', args=[obj.transfer.destination_point.id]) + return format_html('{}', url, obj.transfer.destination_point.name) + return '-' + destination_point_name.short_description = 'Destination Point Name' + destination_point_name.short_description = 'Destination Point Name' + destination_point_name.admin_order_field = 'transfer__destination_point__name' def get_queryset(self, request): qs = models.Item.all_objects\ @@ -208,7 +232,8 @@ def import_data(self, workbook): # first create a list of objects in memory from the file imported_vendor_numbers = set() imported_material_numbers = set() - imported_destination_names = set() + # imported_destination_names = set() + imported_partner_destination_name_pair = set() imported_records = [] for row in range(1, sheet.max_row): import_dict = {} @@ -222,7 +247,11 @@ def import_data(self, workbook): for imp_record in imported_records: imported_vendor_numbers.add(imp_record['transfer__partner_organization__vendor_number']) imported_material_numbers.add(imp_record['material__number']) - imported_destination_names.add(imp_record['transfer__destination_point__name']) + # imported_destination_names.add(imp_record['transfer__destination_point__name']) + imported_partner_destination_name_pair.add( + (imp_record['transfer__partner_organization__vendor_number'], + imp_record['transfer__destination_point__name']) + ) def filter_records(dict_key, model, filter_name, imported_set, recs): # print("###############", imported_set, dict_key, model.__name__, filter_name, recs) @@ -235,6 +264,24 @@ def filter_records(dict_key, model, filter_name, imported_set, recs): return qs, [d for d in recs if d[dict_key] in available_items] + def filter_complex_records(dict_keys, model, filter_names, imported_set, recs): + # Initialize the query set + qs = model.objects.none() + for tuple_pair in imported_set: + filter_kwargs = {filter_names[i]: tuple_pair[i] for i in range(len(tuple_pair))} + qs = qs | model.objects.filter(**filter_kwargs) + + available_items = qs.values_list(*filter_names) + available_set = set(available_items) + + dropped_recs = [d for d in recs if (d[dict_keys[0]], d[dict_keys[1]]) not in available_set] + if dropped_recs: + logging.error( + f"Dropping the following lines as records not available in the workspace for type {model.__name__}" + f" '{dropped_recs}' Please add the related records if needed") + + return qs, [d for d in recs if (d[dict_keys[0]], d[dict_keys[1]]) in available_set] + partner_org_qs, imported_records = filter_records( dict_key="transfer__partner_organization__vendor_number", model=PartnerOrganization, @@ -253,14 +300,27 @@ def filter_records(dict_key, model, filter_name, imported_set, recs): ) material_dict = {m.number: m for m in material_qs} - poi_qs, imported_records = filter_records( - dict_key="transfer__destination_point__name", + # poi_qs, imported_records = filter_records( + # dict_key="transfer__destination_point__name", + # model=models.PointOfInterest, + # filter_name="name", + # imported_set=imported_partner_destination_name_pair, + # recs=imported_records + # ) + + poi_qs, imported_records = filter_complex_records( + dict_keys=["transfer__partner_organization__vendor_number", "transfer__destination_point__name"], model=models.PointOfInterest, - filter_name="name", - imported_set=imported_destination_names, + filter_names=["partner_organizations__organization__vendor_number", "name"], + imported_set=imported_partner_destination_name_pair, recs=imported_records ) - poi_dict = {poi.name: poi for poi in poi_qs.prefetch_related("partner_organizations")} + + poi_dict = {} + for poi in poi_qs.prefetch_related("partner_organizations"): + for partner_org in poi.partner_organizations.all(): + dict_key = partner_org.vendor_number + poi.name + poi_dict[dict_key] = poi transfers = {} @@ -276,7 +336,7 @@ def get_or_create_transfer(filter_dict): for imp_r in imported_records: material = material_dict[imp_r.pop("material__number")] partner = partner_dict[imp_r.pop("transfer__partner_organization__vendor_number")] - poi = poi_dict[imp_r.pop("transfer__destination_point__name")] + poi = poi_dict[partner.vendor_number + imp_r.pop("transfer__destination_point__name")] # ensure the POI belongs to the partner else skip: if partner not in poi.partner_organizations.all(): logging.error(f"skipping record as POI {poi} does not belong to the Partner Org: {partner}")