Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/etools/applications/last_mile/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ class Meta:
model = models.PointOfInterest
fields = '__all__'

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['partner_organizations'].queryset = PartnerOrganization.all_partners.all()
if self.instance and self.instance.pk:
self.initial['partner_organizations'] = list(
self.instance.partner_organizations.through.objects
.filter(pointofinterest_id=self.instance.pk)
.values_list('partnerorganization_id', flat=True)
)

def clean_l_consignee_code(self):
value = self.cleaned_data.get('l_consignee_code')
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def validate_transfer_items(self, transfer: Transfer):
raise ValidationError(_(TRANSFER_HAS_NO_ITEMS))

def validate_transfer_type(self, transfer: Transfer):
if transfer.transfer_type == Transfer.HANDOVER:
if transfer.transfer_type in [Transfer.HANDOVER, Transfer.UNICEF_HANDOVER]:
raise ValidationError(_(TRANSFER_TYPE_HANDOVER_NOT_ALLOWED))

def validate_uom(self, uom: str):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.26 on 2026-02-13 14:57

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('last_mile', '0020_transfer_is_hidden'),
]

operations = [
migrations.AlterField(
model_name='transfer',
name='transfer_type',
field=models.CharField(blank=True, choices=[('DELIVERY', 'Delivery'), ('DISTRIBUTION', 'Distribution'), ('HANDOVER', 'Handover'), ('WASTAGE', 'Wastage'), ('DISPENSE', 'Dispense'), ('UNICEF_HANDOVER', 'UNICEF Handover')], max_length=30, null=True),
),
]
5 changes: 4 additions & 1 deletion src/etools/applications/last_mile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ class ApprovalStatus(models.TextChoices):
DELIVERY = 'DELIVERY'
DISTRIBUTION = 'DISTRIBUTION'
HANDOVER = 'HANDOVER'
UNICEF_HANDOVER = 'UNICEF_HANDOVER'
WASTAGE = 'WASTAGE'
DISPENSE = 'DISPENSE'

Expand All @@ -412,7 +413,9 @@ class ApprovalStatus(models.TextChoices):
(DISTRIBUTION, _('Distribution')),
(HANDOVER, _('Handover')),
(WASTAGE, _('Wastage')),
(DISPENSE, _('Dispense'))
(DISPENSE, _('Dispense')),
(UNICEF_HANDOVER, _('UNICEF Handover')),

)
TRANSFER_SUBTYPE = (
(SHORT, _('Short')),
Expand Down
5 changes: 3 additions & 2 deletions src/etools/applications/last_mile/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ class TransferSerializer(serializers.ModelSerializer):

def get_items(self, obj):
partner = self.context.get('partner')
if obj.transfer_type == obj.HANDOVER and obj.from_partner_organization == partner and obj.initial_items:
if obj.transfer_type in [obj.HANDOVER, obj.UNICEF_HANDOVER] and obj.from_partner_organization == partner and obj.initial_items:
return obj.initial_items
return ItemSerializer(obj.items.all(), many=True).data

Expand Down Expand Up @@ -340,6 +340,7 @@ class Meta:
def get_transfer_name(validated_data, transfer_type=None):
prefix_mapping = {
"HANDOVER": "HO",
"UNICEF_HANDOVER": "UHO",
"WASTAGE": "W",
"DELIVERY": "DW",
"DISTRIBUTION": "DD",
Expand Down Expand Up @@ -625,7 +626,7 @@ def _generate_attachment_url(self):
return attachment_url

def _create_partner_transfer(self, partner_id: int, validated_data: dict):
if validated_data['transfer_type'] == models.Transfer.HANDOVER:
if validated_data['transfer_type'] in [models.Transfer.HANDOVER, models.Transfer.UNICEF_HANDOVER]:
validated_data['recipient_partner_organization_id'] = partner_id
validated_data['from_partner_organization_id'] = self.context['request'].user.profile.organization.partner.pk

Expand Down
184 changes: 184 additions & 0 deletions src/etools/applications/last_mile/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1510,3 +1510,187 @@ def test_audit_log_data_integrity_across_operations(self):

all_item_audits = models.ItemAuditLog.objects.filter(item_id=item.id).order_by('created')
self.assertEqual(all_item_audits.count(), len(operations))


class TestUnicefHandoverCheckoutView(BaseTenantTestCase):
fixtures = ('poi_type.json',)

url = reverse('last_mile:unicef-new-handover-checkout')

@classmethod
def setUpTestData(cls):
cls.partner = PartnerFactory(organization=OrganizationFactory(name='Partner'))
cls.recipient_partner = PartnerFactory(organization=OrganizationFactory(name='Recipient'))
cls.partner_staff = UserFactory(
realms__data=['IP LM Editor'],
profile__organization=cls.partner.organization,
)
cls.origin = PointOfInterestFactory(partner_organizations=[cls.partner], private=True, poi_type_id=1)
cls.destination = PointOfInterestFactory(partner_organizations=[cls.partner], private=True, poi_type_id=3)

cls.checked_in = TransferFactory(
partner_organization=cls.partner,
status=models.Transfer.COMPLETED,
destination_point=cls.origin,
)
cls.material = MaterialFactory(number='UHO_MAT', original_uom='EA')
cls.attachment = AttachmentFactory(
file=SimpleUploadedFile('proof_file.pdf', b'Proof File'), code='proof_of_transfer')

def _build_checkout_data(self, items, **overrides):
data = {
"transfer_type": models.Transfer.UNICEF_HANDOVER,
"origin_point": self.origin.pk,
"destination_point": self.destination.pk,
"comment": "",
"proof_file": self.attachment.pk,
"partner_id": self.recipient_partner.id,
"items": items,
"origin_check_out_at": timezone.now(),
}
data.update(overrides)
return data

def test_unicef_handover_checkout_success(self):
item = ItemFactory(quantity=10, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 10}],
)
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['transfer_type'], models.Transfer.UNICEF_HANDOVER)
self.assertEqual(response.data['status'], models.Transfer.COMPLETED)

transfer = models.Transfer.objects.get(pk=response.data['id'])
self.assertEqual(transfer.origin_point, self.origin)
self.assertEqual(transfer.destination_point, self.destination)
self.assertEqual(transfer.status, models.Transfer.COMPLETED)
self.assertEqual(transfer.destination_check_in_at, transfer.origin_check_out_at)
self.assertEqual(transfer.recipient_partner_organization, self.recipient_partner)
self.assertEqual(transfer.from_partner_organization, self.partner)
self.assertEqual(transfer.items.count(), 1)
self.assertEqual(transfer.items.first().quantity, 10)
self.assertIn(
f'UHO @ {data["origin_check_out_at"].strftime("%y-%m-%d")}',
transfer.name,
)

def test_unicef_handover_partial_quantity(self):
item = ItemFactory(quantity=20, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 8}],
)
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_200_OK)
transfer = models.Transfer.objects.get(pk=response.data['id'])
self.assertEqual(transfer.status, models.Transfer.COMPLETED)
self.assertEqual(transfer.items.first().quantity, 8)

item.refresh_from_db()
self.assertEqual(item.quantity, 12)

def test_rejects_non_unicef_handover_type(self):
item = ItemFactory(quantity=10, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 5}],
transfer_type=models.Transfer.HANDOVER,
)
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_rejects_missing_origin_point(self):
item = ItemFactory(quantity=10, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 5}],
)
del data['origin_point']
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_rejects_missing_destination_point(self):
item = ItemFactory(quantity=10, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 5}],
)
del data['destination_point']
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_rejects_missing_partner_id(self):
item = ItemFactory(quantity=10, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 5}],
)
del data['partner_id']
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_rejects_missing_proof_file(self):
item = ItemFactory(quantity=10, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 5}],
)
del data['proof_file']
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_rejects_quantity_exceeding_available(self):
item = ItemFactory(quantity=5, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 10}],
)
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_multiple_items(self):
item_1 = ItemFactory(quantity=15, transfer=self.checked_in, material=self.material)
material_2 = MaterialFactory(number='UHO_MAT2', original_uom='EA')
item_2 = ItemFactory(quantity=25, transfer=self.checked_in, material=material_2)

data = self._build_checkout_data(
items=[
{"id": item_1.pk, "quantity": 10},
{"id": item_2.pk, "quantity": 20},
],
)
response = self.forced_auth_req('post', self.url, user=self.partner_staff, data=data)

self.assertEqual(response.status_code, status.HTTP_200_OK)
transfer = models.Transfer.objects.get(pk=response.data['id'])
self.assertEqual(transfer.status, models.Transfer.COMPLETED)
self.assertEqual(transfer.items.count(), 2)

item_1.refresh_from_db()
item_2.refresh_from_db()
self.assertEqual(item_1.quantity, 5)
self.assertEqual(item_2.quantity, 5)

def test_viewer_cannot_checkout(self):
viewer = UserFactory(
realms__data=['IP LM Viewer'],
profile__organization=self.partner.organization,
)
item = ItemFactory(quantity=10, transfer=self.checked_in, material=self.material)

data = self._build_checkout_data(
items=[{"id": item.pk, "quantity": 5}],
)
response = self.forced_auth_req('post', self.url, user=viewer, data=data)

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
15 changes: 15 additions & 0 deletions src/etools/applications/last_mile/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,19 @@
view=views.InventoryItemListView.as_view(http_method_names=['get'],),
name='inventory-item-list',
),
path(
'unicef/partner/',
view=views.UnicefPartnerView.as_view(),
name='unicef-partner',
),
path(
'unicef/locations/',
view=views.UnicefLocationsView.as_view(),
name='unicef-locations',
),
path(
'unicef/new-handover/checkout/',
view=views.UnicefHandoverCheckoutView.as_view(),
name='unicef-new-handover-checkout',
),
]
4 changes: 2 additions & 2 deletions src/etools/applications/last_mile/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
class TransferCheckOutValidator:

def validate_destination_points(self, tranfer_type: str, destination_point: int) -> None:
if tranfer_type not in [Transfer.WASTAGE, Transfer.DISPENSE, Transfer.HANDOVER] and not destination_point:
if tranfer_type not in [Transfer.WASTAGE, Transfer.DISPENSE, Transfer.HANDOVER, Transfer.UNICEF_HANDOVER] and not destination_point:
raise ValidationError(_('Destination location is mandatory at checkout.'))

def validate_proof_file(self, proof_file: int) -> None:
if not proof_file:
raise ValidationError(_('The proof file is required.'))

def validate_handover(self, transfer_type: str, partner_id: int) -> None:
if transfer_type == Transfer.HANDOVER and not partner_id:
if transfer_type in [Transfer.HANDOVER, Transfer.UNICEF_HANDOVER] and not partner_id:
raise ValidationError(_('A Handover to a partner requires a partner id.'))
Loading