From 2660d5355fc46614d4203cf9c04264930d15c0f3 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 22:11:30 +0530 Subject: [PATCH 01/10] - get drivers and update their status endpoints --- fleet/services/__init__.py | 0 fleet/services/status_services.py | 19 +++++++ fleet/tests/test_vehicle_api.py | 43 ++++++++++++++ fleet/views/vehicle.py | 94 ++++++++++++++++++++----------- 4 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 fleet/services/__init__.py create mode 100644 fleet/services/status_services.py diff --git a/fleet/services/__init__.py b/fleet/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fleet/services/status_services.py b/fleet/services/status_services.py new file mode 100644 index 0000000..0afb4af --- /dev/null +++ b/fleet/services/status_services.py @@ -0,0 +1,19 @@ +from django.utils import timezone +from fleet.models import Vehicle + +def update_vehicle_status(vehicle: Vehicle, new_status: str): + vehicle.status = new_status + vehicle.updated_at = timezone.now() + vehicle.save(update_fields=['status', 'updated_at']) + +def mark_vehicle_available(vehicle: Vehicle): + update_vehicle_status(vehicle, 'available') + +def mark_vehicle_assigned(vehicle: Vehicle): + update_vehicle_status(vehicle, 'assigned') + +def mark_vehicle_maintenance(vehicle: Vehicle): + update_vehicle_status(vehicle, 'maintenance') + +def mark_vehicle_out_of_service(vehicle: Vehicle): + update_vehicle_status(vehicle, 'out_of_service') diff --git a/fleet/tests/test_vehicle_api.py b/fleet/tests/test_vehicle_api.py index 17f86a1..502acb4 100644 --- a/fleet/tests/test_vehicle_api.py +++ b/fleet/tests/test_vehicle_api.py @@ -98,3 +98,46 @@ def test_update_vehicle_location(self): self.assertEqual(history.count(), 1) self.assertAlmostEqual(float(history[0].speed), 65.5) self.assertAlmostEqual(float(history[0].latitude), 42.123456) + + def test_list_all_vehicles(self): + response = self.client.get("/api/fleet/vehicles/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 3) + + def test_filter_by_status(self): + response = self.client.get("/api/fleet/vehicles/?status=assigned") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['vehicle_id'], "TRK003") + + def test_ordering_by_updated_at(self): + response = self.client.get("/api/fleet/vehicles/?ordering=-updated_at") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(len(response.data) >= 1) + self.assertIn('updated_at', response.data[0]) + + def test_mark_vehicle_available(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle3.id}/mark_available/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.vehicle3.refresh_from_db() + self.assertEqual(self.vehicle3.status, 'available') + + def test_mark_vehicle_assigned(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle1.id}/mark_assigned/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.vehicle1.refresh_from_db() + self.assertEqual(self.vehicle1.status, 'assigned') + + def test_change_status_to_available(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle2.id}/change_status/", { + "status": "available" + }, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.vehicle2.refresh_from_db() + self.assertEqual(self.vehicle2.status, "available") + + def test_change_status_invalid(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle1.id}/change_status/", { + "status": "nonexistent" + }, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/fleet/views/vehicle.py b/fleet/views/vehicle.py index 1ece585..8fb24b0 100644 --- a/fleet/views/vehicle.py +++ b/fleet/views/vehicle.py @@ -1,6 +1,8 @@ import os import django +from fleet.services.status_services import mark_vehicle_assigned, mark_vehicle_available, update_vehicle_status + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logistics_core.settings') django.setup() @@ -58,9 +60,34 @@ def get_queryset(self): queryset = queryset.filter(status='available') if depot := params.get('depot_id'): queryset = queryset.filter(depot_id=depot) - + queryset = queryset.order_by('-updated_at') return queryset + @action(detail=True, methods=['post']) + def mark_available(self, request, pk=None): + vehicle = self.get_object() + mark_vehicle_available(vehicle) + return Response({'vehicle_id': vehicle.vehicle_id, 'status': 'available'}) + + @action(detail=True, methods=['post']) + def mark_assigned(self, request, pk=None): + vehicle = self.get_object() + mark_vehicle_assigned(vehicle) + return Response({'vehicle_id': vehicle.vehicle_id, 'status': 'assigned'}) + + # Admin only + @action(detail=True, methods=['post']) + def change_status(self, request, pk=None): + vehicle = self.get_object() + new_status = request.data.get('status') + + valid_statuses = dict(Vehicle.STATUS_CHOICES).keys() + if new_status not in valid_statuses: + return Response({'error': f'Invalid status. Must be one of {list(valid_statuses)}'}, status=400) + + update_vehicle_status(vehicle, new_status) + return Response({'vehicle_id': vehicle.vehicle_id, 'status': new_status}) + @action(detail=True, methods=['post']) def update_location(self, request, pk=None): vehicle = self.get_object() @@ -85,38 +112,6 @@ def update_location(self, request, pk=None): except Exception as e: return Response({'error': str(e)}, status=400) - @action(detail=True, methods=['post']) - def change_status(self, request, pk=None): - vehicle = self.get_object() - new_status = request.data.get('status') - - if not new_status: - return Response({'error': 'Status is required'}, status=400) - - if new_status not in dict(Vehicle.STATUS_CHOICES): - return Response({'error': f'Invalid status: {new_status}'}, status=400) - - if new_status == 'maintenance' and vehicle.status != 'maintenance' and settings.ENABLE_FLEET_EXTENDED_MODELS: - maintenance_type = request.data.get('maintenance_type', 'routine') - description = request.data.get('description', 'Routine maintenance') - scheduled_date = request.data.get('scheduled_date', timezone.now().date().isoformat()) - try: - scheduled_date = datetime.fromisoformat(scheduled_date).date() - except ValueError: - scheduled_date = timezone.now().date() - - MaintenanceRecord.objects.create( - vehicle=vehicle, - maintenance_type=maintenance_type, - description=description, - scheduled_date=scheduled_date, - status='in_progress' - ) - - vehicle.status = new_status - vehicle.save(update_fields=['status', 'updated_at']) - return Response(VehicleSerializer(vehicle).data) - @action(detail=True, methods=['post']) def assign_depot(self, request, pk=None): """ @@ -197,3 +192,36 @@ def depot_stats(self, request): ).order_by('depot_id') return Response({'by_depot': stats}) + + # # To be implemented with maintenance part + # @action(detail=True, methods=['post']) + # def change_status(self, request, pk=None): + # vehicle = self.get_object() + # new_status = request.data.get('status') + # + # if not new_status: + # return Response({'error': 'Status is required'}, status=400) + # + # if new_status not in dict(Vehicle.STATUS_CHOICES): + # return Response({'error': f'Invalid status: {new_status}'}, status=400) + # + # if new_status == 'maintenance' and vehicle.status != 'maintenance' and settings.ENABLE_FLEET_EXTENDED_MODELS: + # maintenance_type = request.data.get('maintenance_type', 'routine') + # description = request.data.get('description', 'Routine maintenance') + # scheduled_date = request.data.get('scheduled_date', timezone.now().date().isoformat()) + # try: + # scheduled_date = datetime.fromisoformat(scheduled_date).date() + # except ValueError: + # scheduled_date = timezone.now().date() + # + # MaintenanceRecord.objects.create( + # vehicle=vehicle, + # maintenance_type=maintenance_type, + # description=description, + # scheduled_date=scheduled_date, + # status='in_progress' + # ) + # + # vehicle.status = new_status + # vehicle.save(update_fields=['status', 'updated_at']) + # return Response(VehicleSerializer(vehicle).data) \ No newline at end of file From 7238c015c5858d1d8e069b0d95d2e594e10d6fbe Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 06:44:00 +0530 Subject: [PATCH 02/10] Change vehicle status after assigning a task --- assignment/services/assignment_planner.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py index f333d0f..49d6fea 100644 --- a/assignment/services/assignment_planner.py +++ b/assignment/services/assignment_planner.py @@ -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 vehichle in vrp solver assignments = [] for i, route in enumerate(result["routes"]): vehicle = self.vehicles[i] @@ -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: From 6eef678a33ec626f20a6fa59fe9a4e6deb8c3970 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 06:50:39 +0530 Subject: [PATCH 03/10] Added role to assignment_item so the pickup or delivery status can be easily identified --- .../migrations/0002_assignmentitem_role.py | 18 ++++++++++++++++++ assignment/models/assignment_item.py | 12 +++++++++--- assignment/services/assignment_planner.py | 3 ++- assignment/views.py | 8 +++++++- 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 assignment/migrations/0002_assignmentitem_role.py diff --git a/assignment/migrations/0002_assignmentitem_role.py b/assignment/migrations/0002_assignmentitem_role.py new file mode 100644 index 0000000..0b8a7d4 --- /dev/null +++ b/assignment/migrations/0002_assignmentitem_role.py @@ -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), + ), + ] diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py index c649b9c..3dd2351 100644 --- a/assignment/models/assignment_item.py +++ b/assignment/models/assignment_item.py @@ -1,15 +1,21 @@ 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 + is_delivered = models.BooleanField(default=False) delivered_at = models.DateTimeField(null=True, blank=True) @@ -17,4 +23,4 @@ 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}" diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py index 49d6fea..42c7b07 100644 --- a/assignment/services/assignment_planner.py +++ b/assignment/services/assignment_planner.py @@ -86,7 +86,8 @@ def plan_assignments(self) -> List[Assignment]: delivery_location={ "lat": loc["lat"], "lng": loc["lng"], - } + }, + role=role ) seq += 1 diff --git a/assignment/views.py b/assignment/views.py index 6abfff1..9c0593f 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -40,7 +40,12 @@ def create(self, request, *args, **kwargs): for delivery in deliveries: shipment_id = delivery.get("shipment_id") location = delivery.get("location") - sequence = delivery.get("sequence", 1) # fallback if sequence not provided + sequence = delivery.get("sequence", 1) + role = delivery.get("role") + + if role not in ["pickup", "delivery"]: + return Response({"error": f"Invalid role for shipment {shipment_id}. Must be 'pickup' or 'delivery'."}, + status=400) try: shipment = Shipment.objects.get(id=shipment_id) @@ -52,6 +57,7 @@ def create(self, request, *args, **kwargs): shipment=shipment, delivery_sequence=sequence, delivery_location=location, + role=role ) serializer = self.get_serializer(assignment) From a6417248531ca61b135d93e4542a488afc27dd72 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 07:44:42 +0530 Subject: [PATCH 04/10] Get assignment by vehicle id --- assignment/serializers.py | 9 --- assignment/serializers/__init__.py | 0 assignment/serializers/assignment.py | 11 ++++ assignment/serializers/assignment_item.py | 16 +++++ assignment/tests/test_assignment_api.py | 79 +++++++++++++++++++++++ assignment/views.py | 17 ++++- 6 files changed, 122 insertions(+), 10 deletions(-) delete mode 100644 assignment/serializers.py create mode 100644 assignment/serializers/__init__.py create mode 100644 assignment/serializers/assignment.py create mode 100644 assignment/serializers/assignment_item.py create mode 100644 assignment/tests/test_assignment_api.py diff --git a/assignment/serializers.py b/assignment/serializers.py deleted file mode 100644 index 6067b65..0000000 --- a/assignment/serializers.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework import serializers - -from assignment.models.assignment import Assignment - - -class AssignmentSerializer(serializers.ModelSerializer): - class Meta: - model = Assignment - fields = '__all__' diff --git a/assignment/serializers/__init__.py b/assignment/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/serializers/assignment.py b/assignment/serializers/assignment.py new file mode 100644 index 0000000..5fc601a --- /dev/null +++ b/assignment/serializers/assignment.py @@ -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) # ✅ Fix here + + class Meta: + model = Assignment + fields = ['id', 'vehicle', 'total_load', 'status', 'items'] \ No newline at end of file diff --git a/assignment/serializers/assignment_item.py b/assignment/serializers/assignment_item.py new file mode 100644 index 0000000..d77b718 --- /dev/null +++ b/assignment/serializers/assignment_item.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from assignment.models.assignment_item import AssignmentItem +from shipments.models import Shipment + + +class ShipmentSerializer(serializers.ModelSerializer): + class Meta: + model = Shipment + fields = ['id', 'order_id', 'demand', 'status'] # Add more as needed + +class AssignmentItemSerializer(serializers.ModelSerializer): + shipment = ShipmentSerializer(read_only=True) + + class Meta: + model = AssignmentItem + fields = ['shipment', 'role', 'delivery_sequence', 'delivery_location', 'is_delivered', 'delivered_at'] diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py new file mode 100644 index 0000000..da58a22 --- /dev/null +++ b/assignment/tests/test_assignment_api.py @@ -0,0 +1,79 @@ +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( + 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") # Default DRF route for create + 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): + # First create assignment + 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) diff --git a/assignment/views.py b/assignment/views.py index 9c0593f..856333f 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -1,11 +1,12 @@ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.response import Response from .models.assignment import Assignment from .models.assignment_item import AssignmentItem -from .serializers import AssignmentSerializer from fleet.models import Vehicle from shipments.models import Shipment +from .serializers.assignment import AssignmentSerializer class AssignmentViewSet(viewsets.ModelViewSet): @@ -62,3 +63,17 @@ def create(self, request, *args, **kwargs): serializer = self.get_serializer(assignment) return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=["get"], url_path="by-vehicle/(?P[^/.]+)") + def by_vehicle(self, request, vehicle_id=None): + try: + vehicle = Vehicle.objects.get(vehicle_id=vehicle_id) + except Vehicle.DoesNotExist: + return Response({"error": "Vehicle not found"}, status=404) + + assignment = Assignment.objects.filter(vehicle=vehicle).order_by('-id').first() + if not assignment: + return Response({"message": "No assignment found for this vehicle"}, status=404) + + serializer = self.get_serializer(assignment) + return Response(serializer.data) \ No newline at end of file From 7ab6350bf0bc676f1aef6a46cca0ba51de578816 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 09:44:55 +0530 Subject: [PATCH 05/10] Endpoint for report when reaching endpoint --- assignment/serializers/assignment_item.py | 4 +- assignment/tests/test_assignment_api.py | 99 ++++++++++++++++++++++- assignment/urls.py | 2 +- assignment/views.py | 59 ++++++++++++-- 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/assignment/serializers/assignment_item.py b/assignment/serializers/assignment_item.py index d77b718..72c67a4 100644 --- a/assignment/serializers/assignment_item.py +++ b/assignment/serializers/assignment_item.py @@ -3,13 +3,13 @@ from shipments.models import Shipment -class ShipmentSerializer(serializers.ModelSerializer): +class ShipmentSerializerForAssignment(serializers.ModelSerializer): class Meta: model = Shipment fields = ['id', 'order_id', 'demand', 'status'] # Add more as needed class AssignmentItemSerializer(serializers.ModelSerializer): - shipment = ShipmentSerializer(read_only=True) + shipment = ShipmentSerializerForAssignment(read_only=True) class Meta: model = AssignmentItem diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py index da58a22..25fcf5e 100644 --- a/assignment/tests/test_assignment_api.py +++ b/assignment/tests/test_assignment_api.py @@ -1,3 +1,5 @@ +import uuid + from django.urls import reverse from rest_framework.test import APITestCase from rest_framework import status @@ -19,6 +21,7 @@ def setUp(self): ) self.shipment = Shipment.objects.create( + shipment_id=str(uuid.uuid4()), order_id="ORD001", demand=500, origin={"lat": 7.2, "lng": 80.1}, @@ -26,7 +29,7 @@ def setUp(self): status="pending" ) - self.create_url = reverse("assignment-list") # Default DRF route for create + 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): @@ -59,7 +62,6 @@ def test_create_assignment(self): self.assertEqual(assignment.total_load, 500) def test_get_assignment_by_vehicle(self): - # First create assignment assignment = Assignment.objects.create( vehicle=self.vehicle, total_load=500, @@ -77,3 +79,96 @@ def test_get_assignment_by_vehicle(self): 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") + print(response.data) + + 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) diff --git a/assignment/urls.py b/assignment/urls.py index d1d1500..8c4dd67 100644 --- a/assignment/urls.py +++ b/assignment/urls.py @@ -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)), diff --git a/assignment/views.py b/assignment/views.py index 856333f..becb0ae 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -1,10 +1,11 @@ +from django.utils import timezone from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from .models.assignment import Assignment from .models.assignment_item import AssignmentItem -from fleet.models import Vehicle +from fleet.models import Vehicle, VehicleLocation from shipments.models import Shipment from .serializers.assignment import AssignmentSerializer @@ -18,26 +19,20 @@ def create(self, request, *args, **kwargs): if not deliveries: return Response({"error": "Deliveries required"}, status=400) - # Calculate total load total_load = sum(d.get("load", 0) for d in deliveries) - - # Find an available vehicle that can handle the load vehicle = Vehicle.objects.filter(status="available", capacity__gte=total_load).first() if not vehicle: return Response({"error": "No available vehicle for the load"}, status=400) - # Update vehicle status vehicle.status = "assigned" vehicle.save() - # Create Assignment assignment = Assignment.objects.create( vehicle=vehicle, total_load=total_load, status='created' ) - # Create AssignmentItem entries for delivery in deliveries: shipment_id = delivery.get("shipment_id") location = delivery.get("location") @@ -76,4 +71,52 @@ def by_vehicle(self, request, vehicle_id=None): return Response({"message": "No assignment found for this vehicle"}, status=404) serializer = self.get_serializer(assignment) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) + + @action(detail=True, methods=['post'], url_path='arrive/sequence/(?P[0-9]+)') + def mark_arrival(self, request, pk=None, sequence=None): + assignment = self.get_object() + vehicle = assignment.vehicle + sequence = int(sequence) + + try: + current_item = assignment.items.get(delivery_sequence=sequence) + except AssignmentItem.DoesNotExist: + return Response({"error": f"No assignment item found at sequence {sequence}"}, status=404) + + location = current_item.delivery_location + lat, lng = location.get("lat"), location.get("lng") + + if lat is None or lng is None: + return Response({"error": "Location data is missing in assignment item"}, status=400) + + vehicle.update_location(lat, lng) + VehicleLocation.objects.create( + vehicle=vehicle, + latitude=lat, + longitude=lng, + ) + + items_at_location = assignment.items.filter( + delivery_location=location, + delivery_sequence__gte=sequence + ).order_by("delivery_sequence") + + grouped = [ + { + "assignment_item_id": item.id, + "role": item.role, + "shipment_id": item.shipment.id, + "shipment_status": item.shipment.status, + "location": item.delivery_location, + "is_delivered": item.is_delivered + } + for item in items_at_location + ] + + return Response({ + "vehicle": vehicle.vehicle_id, + "arrived_at": timezone.now(), + "location": location, + "actions": grouped + }) From 445b791e89aeaf9d54e895ac811a86d95409b932 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 09:57:10 +0530 Subject: [PATCH 06/10] Endpoint to confirm pickup and delivery --- assignment/models/assignment_item.py | 1 + assignment/tests/test_assignment_complete.py | 91 ++++++++++++++++++++ assignment/views.py | 30 +++++++ 3 files changed, 122 insertions(+) create mode 100644 assignment/tests/test_assignment_complete.py diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py index 3dd2351..8c2236c 100644 --- a/assignment/models/assignment_item.py +++ b/assignment/models/assignment_item.py @@ -16,6 +16,7 @@ class AssignmentItem(models.Model): role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="delivery") # NEW + # TODO: rename these fields is_delivered = models.BooleanField(default=False) delivered_at = models.DateTimeField(null=True, blank=True) diff --git a/assignment/tests/test_assignment_complete.py b/assignment/tests/test_assignment_complete.py new file mode 100644 index 0000000..fcbe86c --- /dev/null +++ b/assignment/tests/test_assignment_complete.py @@ -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") diff --git a/assignment/views.py b/assignment/views.py index becb0ae..b1f31e1 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -120,3 +120,33 @@ def mark_arrival(self, request, pk=None, sequence=None): "location": location, "actions": grouped }) + + @action(detail=True, methods=["post"], url_path="actions/(?P[0-9]+)/complete") + def mark_action_complete(self, request, pk=None, item_id=None): + try: + assignment = self.get_object() + item = assignment.items.get(id=item_id) + except AssignmentItem.DoesNotExist: + return Response({"error": "Assignment item not found"}, status=404) + + if item.is_delivered: + return Response({"message": "Already marked complete"}, status=200) + + item.is_delivered = True + item.delivered_at = timezone.now() + item.save(update_fields=["is_delivered", "delivered_at"]) + + # Optional: update shipment status + if item.role == "delivery": + item.shipment.mark_delivered() + elif item.role == "pickup": + item.shipment.mark_dispatched() + item.shipment.mark_in_transit() + item.shipment.save() + + return Response({ + "message": f"{item.role.title()} confirmed", + "shipment_id": item.shipment.id, + "new_status": item.shipment.status, + "timestamp": item.delivered_at + }, status=200) From 01c646b207eb7a4c4d9b399b8a89b392fdc79e43 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:02:03 +0530 Subject: [PATCH 07/10] Update assignment/services/assignment_planner.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/services/assignment_planner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py index 42c7b07..3e2b7cd 100644 --- a/assignment/services/assignment_planner.py +++ b/assignment/services/assignment_planner.py @@ -54,7 +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 vehichle in vrp solver + # Implicit mapping of vehicle in this and vehicle in vrp solver assignments = [] for i, route in enumerate(result["routes"]): vehicle = self.vehicles[i] From 63b74e113d56232bf4255c9e07847ec6c70ed2a7 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:02:10 +0530 Subject: [PATCH 08/10] Update assignment/serializers/assignment.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/serializers/assignment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assignment/serializers/assignment.py b/assignment/serializers/assignment.py index 5fc601a..2fe7bd2 100644 --- a/assignment/serializers/assignment.py +++ b/assignment/serializers/assignment.py @@ -4,7 +4,7 @@ class AssignmentSerializer(serializers.ModelSerializer): items = AssignmentItemSerializer(many=True, read_only=True) - vehicle = serializers.CharField(source='vehicle.vehicle_id', read_only=True) # ✅ Fix here + vehicle = serializers.CharField(source='vehicle.vehicle_id', read_only=True) class Meta: model = Assignment From 0e93e52c11b786878fd83377c99dded0d3d42889 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:02:20 +0530 Subject: [PATCH 09/10] Update assignment/tests/test_assignment_api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/tests/test_assignment_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py index 25fcf5e..14e8dda 100644 --- a/assignment/tests/test_assignment_api.py +++ b/assignment/tests/test_assignment_api.py @@ -149,7 +149,6 @@ def test_arrival_with_multiple_actions_at_same_location(self): # 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") - print(response.data) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["location"], {"lat": 7.3, "lng": 80.2}) From f1e6dd3bba9587ac953f86fa2309f278c5f4a570 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:05:53 +0530 Subject: [PATCH 10/10] Update assignment/models/assignment_item.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/models/assignment_item.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py index 8c2236c..dadb016 100644 --- a/assignment/models/assignment_item.py +++ b/assignment/models/assignment_item.py @@ -16,7 +16,8 @@ class AssignmentItem(models.Model): role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="delivery") # NEW - # TODO: rename these fields + # 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)