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)