Skip to content
18 changes: 18 additions & 0 deletions assignment/migrations/0002_assignmentitem_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-09 01:18

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('assignment', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='assignmentitem',
name='role',
field=models.CharField(choices=[('pickup', 'Pickup'), ('delivery', 'Delivery')], default='delivery', max_length=10),
),
]
14 changes: 11 additions & 3 deletions assignment/models/assignment_item.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
from django.db import models

from assignment.models.assignment import Assignment
from shipments.models import Shipment


class AssignmentItem(models.Model):
ROLE_CHOICES = [
("pickup", "Pickup"),
("delivery", "Delivery"),
]

assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name='items')
shipment = models.ForeignKey(Shipment, on_delete=models.CASCADE)
delivery_sequence = models.PositiveIntegerField() # 1st, 2nd, 3rd drop, etc.
delivery_sequence = models.PositiveIntegerField() # 1st, 2nd, 3rd stop, etc.
delivery_location = models.JSONField() # { "lat": ..., "lng": ... }

role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="delivery") # NEW

# TODO: Consider renaming 'is_delivered' and 'delivered_at' for better clarity.
# Example: 'is_delivered' -> 'has_been_delivered', 'delivered_at' -> 'delivery_timestamp'.
is_delivered = models.BooleanField(default=False)
delivered_at = models.DateTimeField(null=True, blank=True)

class Meta:
ordering = ['delivery_sequence']

def __str__(self):
return f"Shipment {self.shipment.id} in Assignment {self.assignment.id}"
return f"{self.role.capitalize()} for Shipment {self.shipment.id} in Assignment {self.assignment.id}"
9 changes: 0 additions & 9 deletions assignment/serializers.py

This file was deleted.

Empty file.
11 changes: 11 additions & 0 deletions assignment/serializers/assignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from rest_framework import serializers
from assignment.models.assignment import Assignment
from assignment.serializers.assignment_item import AssignmentItemSerializer

class AssignmentSerializer(serializers.ModelSerializer):
items = AssignmentItemSerializer(many=True, read_only=True)
vehicle = serializers.CharField(source='vehicle.vehicle_id', read_only=True)

class Meta:
model = Assignment
fields = ['id', 'vehicle', 'total_load', 'status', 'items']
16 changes: 16 additions & 0 deletions assignment/serializers/assignment_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from rest_framework import serializers
from assignment.models.assignment_item import AssignmentItem
from shipments.models import Shipment


class ShipmentSerializerForAssignment(serializers.ModelSerializer):
class Meta:
model = Shipment
fields = ['id', 'order_id', 'demand', 'status'] # Add more as needed

class AssignmentItemSerializer(serializers.ModelSerializer):
shipment = ShipmentSerializerForAssignment(read_only=True)

class Meta:
model = AssignmentItem
fields = ['shipment', 'role', 'delivery_sequence', 'delivery_location', 'is_delivered', 'delivered_at']
8 changes: 7 additions & 1 deletion assignment/services/assignment_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def plan_assignments(self) -> List[Assignment]:
logger.error("Optimizer failed to find a solution.")
raise Exception("Optimization failed")

# Implicit mapping of vehicle in this and vehicle in vrp solver
assignments = []
for i, route in enumerate(result["routes"]):
vehicle = self.vehicles[i]
Expand All @@ -66,6 +67,10 @@ def plan_assignments(self) -> List[Assignment]:
status='created'
)

# Update vehicle status, not using methods in the vehicle model but ORM directly
vehicle.status = "assigned"
vehicle.save(update_fields=["status"])

seq = 1
for node in route:
if node in vrp_input.task_index_map:
Expand All @@ -81,7 +86,8 @@ def plan_assignments(self) -> List[Assignment]:
delivery_location={
"lat": loc["lat"],
"lng": loc["lng"],
}
},
role=role
)
seq += 1

Expand Down
173 changes: 173 additions & 0 deletions assignment/tests/test_assignment_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import uuid

from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status

from fleet.models import Vehicle
from shipments.models import Shipment
from assignment.models.assignment import Assignment
from assignment.models.assignment_item import AssignmentItem


class AssignmentAPITests(APITestCase):
def setUp(self):
self.vehicle = Vehicle.objects.create(
vehicle_id="TRK001",
name="Truck 1",
capacity=1000,
status="available",
fuel_type="diesel"
)

self.shipment = Shipment.objects.create(
shipment_id=str(uuid.uuid4()),
order_id="ORD001",
demand=500,
origin={"lat": 7.2, "lng": 80.1},
destination={"lat": 7.3, "lng": 80.2},
status="pending"
)

self.create_url = reverse("assignment-list")
self.by_vehicle_url = lambda v_id: reverse("assignment-by-vehicle", kwargs={"vehicle_id": v_id})

def test_create_assignment(self):
payload = {
"deliveries": [
{
"shipment_id": self.shipment.id,
"location": {"lat": 7.2, "lng": 80.1},
"sequence": 1,
"load": 500,
"role": "pickup"
},
{
"shipment_id": self.shipment.id,
"location": {"lat": 7.3, "lng": 80.2},
"sequence": 2,
"load": 0,
"role": "delivery"
}
]
}

response = self.client.post(self.create_url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Assignment.objects.count(), 1)
self.assertEqual(AssignmentItem.objects.count(), 2)

assignment = Assignment.objects.first()
self.assertEqual(assignment.vehicle.vehicle_id, "TRK001")
self.assertEqual(assignment.total_load, 500)

def test_get_assignment_by_vehicle(self):
assignment = Assignment.objects.create(
vehicle=self.vehicle,
total_load=500,
status="created"
)
AssignmentItem.objects.create(
assignment=assignment,
shipment=self.shipment,
delivery_sequence=1,
delivery_location={"lat": 7.2, "lng": 80.1},
role="pickup"
)

response = self.client.get(self.by_vehicle_url("TRK001"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["vehicle"], self.vehicle.vehicle_id)
self.assertEqual(len(response.data["items"]), 1)

def test_arrival_at_sequence_returns_correct_actions(self):
assignment = Assignment.objects.create(
vehicle=self.vehicle,
total_load=500,
status="created"
)
AssignmentItem.objects.create(
assignment=assignment,
shipment=self.shipment,
delivery_sequence=1,
delivery_location={"lat": 7.2, "lng": 80.1},
role="pickup"
)
AssignmentItem.objects.create(
assignment=assignment,
shipment=self.shipment,
delivery_sequence=2,
delivery_location={"lat": 7.3, "lng": 80.2},
role="delivery"
)

# arrive_url = reverse("assignment-arrive-sequence", kwargs={"pk": assignment.pk, "sequence": 2})
arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/2/"
response = self.client.post(arrive_url, format="json")

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["location"], {"lat": 7.3, "lng": 80.2})
self.assertEqual(len(response.data["actions"]), 1)
self.assertEqual(response.data["actions"][0]["role"], "delivery")
self.assertEqual(response.data["actions"][0]["shipment_id"], self.shipment.id)

def test_arrival_with_multiple_actions_at_same_location(self):
assignment = Assignment.objects.create(
vehicle=self.vehicle,
total_load=800,
status="created"
)

# Add another shipment
shipment2 = Shipment.objects.create(
shipment_id=str(uuid.uuid4()),
order_id="ORD002",
demand=300,
origin={"lat": 7.1, "lng": 80.0},
destination={"lat": 7.3, "lng": 80.2}, # SAME location as the first delivery
status="pending"
)

# First shipment's delivery
AssignmentItem.objects.create(
assignment=assignment,
shipment=self.shipment,
delivery_sequence=2,
delivery_location={"lat": 7.3, "lng": 80.2},
role="delivery"
)

# Second shipment's delivery — same place
AssignmentItem.objects.create(
assignment=assignment,
shipment=shipment2,
delivery_sequence=3,
delivery_location={"lat": 7.3, "lng": 80.2},
role="delivery"
)

# Call the arrival endpoint at sequence 2 (first of the two)
arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/2/"
response = self.client.post(arrive_url, format="json")

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["location"], {"lat": 7.3, "lng": 80.2})
self.assertEqual(len(response.data["actions"]), 2)

roles = [a["role"] for a in response.data["actions"]]
shipment_ids = [a["shipment_id"] for a in response.data["actions"]]

self.assertIn("delivery", roles)
self.assertIn(self.shipment.id, shipment_ids)
self.assertIn(shipment2.id, shipment_ids)

def test_arrival_with_invalid_sequence_returns_404(self):
assignment = Assignment.objects.create(
vehicle=self.vehicle,
total_load=500,
status="created"
)
# arrive_url = reverse("assignment-arrive-sequence", kwargs={"pk": assignment.pk, "sequence": 99})
arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/99/"
response = self.client.post(arrive_url, format="json")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
91 changes: 91 additions & 0 deletions assignment/tests/test_assignment_complete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import uuid
from django.utils import timezone
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status

from fleet.models import Vehicle
from shipments.models import Shipment
from assignment.models.assignment import Assignment
from assignment.models.assignment_item import AssignmentItem


class AssignmentActionCompletionTests(APITestCase):
def setUp(self):
self.vehicle = Vehicle.objects.create(
vehicle_id="TRK001",
name="Truck 1",
capacity=1000,
status="available",
fuel_type="diesel"
)

self.shipment = Shipment.objects.create(
shipment_id=str(uuid.uuid4()),
order_id="ORD001",
demand=500,
origin={"lat": 7.2, "lng": 80.1},
destination={"lat": 7.3, "lng": 80.2},
status="in_transit"
)

self.assignment = Assignment.objects.create(
vehicle=self.vehicle,
total_load=500,
status="created"
)

self.pickup_item = AssignmentItem.objects.create(
assignment=self.assignment,
shipment=self.shipment,
delivery_sequence=1,
delivery_location=self.shipment.origin,
role="pickup",
is_delivered=False
)

self.delivery_item = AssignmentItem.objects.create(
assignment=self.assignment,
shipment=self.shipment,
delivery_sequence=2,
delivery_location=self.shipment.destination,
role="delivery",
is_delivered=False
)

def test_confirm_delivery_action_successfully(self):
url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/"
response = self.client.post(url, format="json")

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Delivery confirmed")
self.assertEqual(response.data["shipment_id"], self.shipment.id)
self.assertEqual(response.data["new_status"], "delivered")

def test_confirm_pickup_action_successfully(self):
self.shipment.status = "scheduled"
self.shipment.save()

url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.pickup_item.id}/complete/"
response = self.client.post(url, format="json")

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Pickup confirmed")
self.assertEqual(response.data["shipment_id"], self.shipment.id)
self.assertEqual(response.data["new_status"], "in_transit")

def test_confirm_action_invalid_assignment_item(self):
url = f"/api/assignment/assignments/{self.assignment.id}/actions/9999/complete/"
response = self.client.post(url, format="json")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

def test_confirm_already_completed_action(self):
self.delivery_item.is_delivered = True
self.delivery_item.delivered_at = timezone.now()
self.delivery_item.save()

url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/"
response = self.client.post(url, format="json")

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Already marked complete")
2 changes: 1 addition & 1 deletion assignment/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .views import AssignmentViewSet

router = DefaultRouter()
router.register(r'assignments', AssignmentViewSet)
router.register(r'assignments', AssignmentViewSet, basename='assignment')

urlpatterns = [
path('', include(router.urls)),
Expand Down
Loading