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/__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' 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'), + ), + ] diff --git a/src/etools/applications/last_mile/admin.py b/src/etools/applications/last_mile/admin.py index b0c9bdc0d5..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 @@ -27,8 +29,14 @@ 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): + readonly_fields = ('partner_names',) list_display = ('name', 'parent', 'poi_type', 'p_code') list_select_related = ('parent',) list_filter = ('private', 'is_active') @@ -48,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']) @@ -128,7 +147,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)\ @@ -170,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\ @@ -203,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 = {} @@ -217,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) @@ -230,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, @@ -248,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 = {} @@ -271,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}") @@ -303,4 +368,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/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/migrations/0005_auto_20240621_0845.py b/src/etools/applications/last_mile/migrations/0005_auto_20240621_0845.py new file mode 100644 index 0000000000..bb1a1d6ffc --- /dev/null +++ b/src/etools/applications/last_mile/migrations/0005_auto_20240621_0845.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.19 on 2024-06-21 10:19 + +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.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=[ + ('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 8dedf0276b..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 = ( @@ -188,6 +190,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")), @@ -333,7 +361,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/serializers.py b/src/etools/applications/last_mile/serializers.py index 677c0bdbfb..b25aa30b10 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 @@ -209,6 +210,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) or not all(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) @@ -233,13 +262,26 @@ 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: 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) @@ -306,10 +348,16 @@ 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"] + 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: orig_items_dict = {obj.id: obj for obj in instance.items.all()} checkedin_items_ids = [r["id"] for r in checkin_items] @@ -355,19 +403,27 @@ 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) 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) @@ -429,19 +485,34 @@ 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 not self.initial_data.get('proof_file'): + raise ValidationError(_('The proof file is required.')) + + 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 validated_data.get('destination_point'): validated_data['destination_point_id'] = validated_data.pop('destination_point') + 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.')) + else: + partner_id = self.context['request'].user.profile.organization.partner.pk + + if not validated_data.get("name"): + validated_data['name'] = self.get_transfer_name(validated_data) + 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) 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() @@ -450,3 +521,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/tests/test_views.py b/src/etools/applications/last_mile/tests/test_views.py index 9b14486606..f2b6afac82 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 @@ -19,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 @@ -43,6 +45,8 @@ def test_api_poi_types_list(self): class TestPointOfInterestView(BaseTenantTestCase): + fixtures = ('poi_type.json',) + @classmethod def setUpTestData(cls): call_command("update_notifications") @@ -51,9 +55,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) @@ -63,7 +68,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) @@ -122,6 +145,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')) @@ -129,33 +154,35 @@ 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, + transfer_type=models.Transfer.DELIVERY ) 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')) + 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) @@ -163,7 +190,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) @@ -171,7 +198,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) @@ -179,7 +206,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) @@ -203,7 +230,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) @@ -218,6 +245,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) @@ -225,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": [ @@ -234,14 +265,15 @@ 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) 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) @@ -251,10 +283,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']) @@ -274,7 +306,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) @@ -322,7 +354,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) @@ -364,21 +396,56 @@ 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_distribution_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.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) + 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) 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": [ @@ -387,7 +454,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) @@ -396,7 +463,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) @@ -424,7 +491,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) @@ -441,6 +508,110 @@ 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) + 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) + 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) + 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) + + 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.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.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) + + 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.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 proof file is required.', response.data) @skip('disabling feature for now') def test_mark_completed(self): @@ -448,7 +619,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) @@ -457,6 +628,36 @@ 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.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) + 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 @@ -550,3 +751,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/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 a6bfd86cf3..938506fd7e 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 @@ -22,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 @@ -73,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 @@ -96,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', @@ -306,6 +320,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] @@ -319,6 +359,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] 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)