From b88baab1c2dda346dfa74bbd703c75409319c981 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Mon, 5 May 2025 14:52:08 +0530 Subject: [PATCH 1/8] Change timezone --- logistics_core/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logistics_core/settings.py b/logistics_core/settings.py index b0d9a93..36fb171 100644 --- a/logistics_core/settings.py +++ b/logistics_core/settings.py @@ -111,7 +111,7 @@ LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +TIME_ZONE = 'Asia/Colombo' USE_I18N = True From 6a4f083b4e66c7363192c25e4273938005d40001 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Mon, 5 May 2025 15:26:14 +0530 Subject: [PATCH 2/8] Clients for other apps so, we can split apps easily with only changing the client --- assignment/clients/fleet_client.py | 31 +++++++++++++++++++++++++++ assignment/clients/shipment_client.py | 14 ++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 assignment/clients/fleet_client.py create mode 100644 assignment/clients/shipment_client.py diff --git a/assignment/clients/fleet_client.py b/assignment/clients/fleet_client.py new file mode 100644 index 0000000..14884eb --- /dev/null +++ b/assignment/clients/fleet_client.py @@ -0,0 +1,31 @@ +from fleet.models import Vehicle + + +class FleetClient: + @staticmethod + def get_available_vehicles(min_capacity=None, limit=None): + """ + fetch available vehicles, optionally filtered by capacity and limited in count. + + Args: + min_capacity (int, optional): Minimum vehicle capacity. + limit (int, optional): Maximum number of vehicles to return. + + Returns: + QuerySet of Vehicle objects + """ + qs = Vehicle.objects.filter(status='available') + if min_capacity is not None: + qs = qs.filter(capacity__gte=min_capacity) + if limit is not None: + qs = qs[:limit] + return qs + + @staticmethod + def get_vehicle_by_id(vehicle_id): + return Vehicle.objects.filter(id=vehicle_id).first() + + @staticmethod + def mark_assigned(vehicle): + vehicle.status = 'assigned' + vehicle.save(update_fields=['status']) diff --git a/assignment/clients/shipment_client.py b/assignment/clients/shipment_client.py new file mode 100644 index 0000000..9ac82b5 --- /dev/null +++ b/assignment/clients/shipment_client.py @@ -0,0 +1,14 @@ +from shipments.models import Shipment + +class ShipmentClient: + @staticmethod + def get_pending_shipments(): + return Shipment.objects.filter(status='pending') + + @staticmethod + def mark_scheduled(shipment, vehicle, dispatch_time=None): + shipment.status = 'scheduled' + shipment.assigned_vehicle_id = vehicle.id + if dispatch_time: + shipment.scheduled_dispatch = dispatch_time + shipment.save(update_fields=['status', 'assigned_vehicle_id', 'scheduled_dispatch']) From 77215c7d6f131e6bafd89a6b42af09c34ee597e1 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 16:01:36 +0530 Subject: [PATCH 3/8] change shipment model --- shipments/admin.py | 22 +++++++++- shipments/consumers/order_events.py | 38 ++++++++++------ ...pment_destination_warehouse_id_and_more.py | 34 +++++++++++++++ shipments/models.py | 6 ++- shipments/tests/test_api.py | 13 +++--- shipments/tests/test_consumer.py | 43 ++++++++----------- shipments/tests/test_integration_kafka.py | 8 ++-- 7 files changed, 112 insertions(+), 52 deletions(-) create mode 100644 shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py diff --git a/shipments/admin.py b/shipments/admin.py index 648e9b2..30b1d50 100644 --- a/shipments/admin.py +++ b/shipments/admin.py @@ -1,9 +1,27 @@ from django.contrib import admin from .models import Shipment + @admin.register(Shipment) class ShipmentAdmin(admin.ModelAdmin): - list_display = ('shipment_id', 'order_id', 'origin_warehouse_id', 'destination_warehouse_id', 'status', 'created_at') - list_filter = ('status', 'origin_warehouse_id', 'destination_warehouse_id') + list_display = ( + 'shipment_id', + 'order_id', + 'get_origin', + 'get_destination', + 'status', + 'created_at' + ) + list_filter = ('status',) search_fields = ('shipment_id', 'order_id') ordering = ('-created_at',) + + @admin.display(description="Origin") + def get_origin(self, obj): + loc = obj.origin_location + return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" + + @admin.display(description="Destination") + def get_destination(self, obj): + loc = obj.destination_location + return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" diff --git a/shipments/consumers/order_events.py b/shipments/consumers/order_events.py index f566009..d2b1a8b 100644 --- a/shipments/consumers/order_events.py +++ b/shipments/consumers/order_events.py @@ -1,9 +1,11 @@ import json import logging +import uuid + from confluent_kafka import Consumer, KafkaException from django.conf import settings from shipments.models import Shipment -import uuid + def create_kafka_consumer(): return Consumer({ @@ -12,22 +14,29 @@ def create_kafka_consumer(): 'auto.offset.reset': 'earliest', }) + def handle_order_created(event): order_id = event.get("order_id") - origin = event.get("origin_warehouse_id") - destination = event.get("destination_warehouse_id") - - if order_id and origin and destination: - Shipment.objects.create( - shipment_id=str(uuid.uuid4())[:12], - order_id=order_id, - origin_warehouse_id=origin, - destination_warehouse_id=destination, - status='pending' - ) - logging.info(f"Shipment created for order {order_id}") - else: + origin = event.get("origin") # Expecting {"lat": ..., "lng": ...} + destination = event.get("destination") + + if not (order_id and origin and destination): logging.error("Invalid order event payload") + return + + if not all(k in origin for k in ("lat", "lng")) or not all(k in destination for k in ("lat", "lng")): + logging.error("Origin/destination must include lat/lng") + return + + Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id=order_id, + origin=origin, + destination=destination, + status='pending' + ) + logging.info(f"Shipment created for order {order_id}") + def start_order_consumer(): consumer = create_kafka_consumer() @@ -48,6 +57,7 @@ def start_order_consumer(): finally: consumer.close() + def run_consumer_once(): consumer = create_kafka_consumer() consumer.subscribe(['orders.created']) diff --git a/shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py b/shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py new file mode 100644 index 0000000..ee53c4d --- /dev/null +++ b/shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2 on 2025-05-08 10:07 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipments', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='shipment', + name='destination_warehouse_id', + ), + migrations.RemoveField( + model_name='shipment', + name='origin_warehouse_id', + ), + migrations.AddField( + model_name='shipment', + name='destination', + field=models.JSONField(), + preserve_default=False, + ), + migrations.AddField( + model_name='shipment', + name='origin', + field=models.JSONField(), + preserve_default=False, + ), + ] diff --git a/shipments/models.py b/shipments/models.py index 70e00d8..a277e2d 100644 --- a/shipments/models.py +++ b/shipments/models.py @@ -14,8 +14,10 @@ class Shipment(models.Model): shipment_id = models.CharField(max_length=32, unique=True) order_id = models.CharField(max_length=32) # Reference to order service - origin_warehouse_id = models.CharField(max_length=36) - destination_warehouse_id = models.CharField(max_length=36) + + origin = models.JSONField() + destination = models.JSONField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') scheduled_dispatch = models.DateTimeField(null=True, blank=True) actual_dispatch = models.DateTimeField(null=True, blank=True) diff --git a/shipments/tests/test_api.py b/shipments/tests/test_api.py index 5983230..cb285de 100644 --- a/shipments/tests/test_api.py +++ b/shipments/tests/test_api.py @@ -6,22 +6,23 @@ from datetime import timedelta from django.core.exceptions import ValidationError + class ShipmentAPITestCase(TestCase): def setUp(self): self.client = APIClient() self.shipment = Shipment.objects.create( shipment_id="SHIP123", order_id="ORD456", - origin_warehouse_id="WH001", - destination_warehouse_id="WH002", + origin={"lat": 6.9271, "lng": 79.8612}, + destination={"lat": 7.2906, "lng": 80.6337} ) def test_create_shipment(self): payload = { "shipment_id": "SHIP999", "order_id": "ORD999", - "origin_warehouse_id": "WH010", - "destination_warehouse_id": "WH020" + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.2, "lng": 80.6} } response = self.client.post("/api/shipments/", payload, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -108,6 +109,6 @@ def test_duplicate_shipment_id(self): Shipment.objects.create( shipment_id="SHIP123", order_id="ORD999", - origin_warehouse_id="WHX", - destination_warehouse_id="WHY" + origin={"lat": 1.0, "lng": 2.0}, + destination={"lat": 3.0, "lng": 4.0} ) diff --git a/shipments/tests/test_consumer.py b/shipments/tests/test_consumer.py index 92b63a4..54de5c9 100644 --- a/shipments/tests/test_consumer.py +++ b/shipments/tests/test_consumer.py @@ -5,24 +5,22 @@ class KafkaConsumerRobustTest(TestCase): def test_valid_order_event_creates_shipment(self): - """A valid event should create a shipment.""" event = { "order_id": "ORD001", - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "origin": {"lat": 6.9271, "lng": 79.8612}, + "destination": {"lat": 7.2906, "lng": 80.6337} } handle_order_created(event) shipment = Shipment.objects.get(order_id="ORD001") self.assertEqual(shipment.status, "pending") - self.assertEqual(shipment.origin_warehouse_id, "WH1") - self.assertEqual(shipment.destination_warehouse_id, "WH2") + self.assertEqual(shipment.origin, {"lat": 6.9271, "lng": 79.8612}) + self.assertEqual(shipment.destination, {"lat": 7.2906, "lng": 80.6337}) def test_missing_order_id_does_not_create_shipment(self): - """Missing order_id should skip creation.""" event = { - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "origin": {"lat": 6.9271, "lng": 79.8612}, + "destination": {"lat": 7.2906, "lng": 80.6337} } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -30,7 +28,7 @@ def test_missing_order_id_does_not_create_shipment(self): def test_missing_origin_does_not_create_shipment(self): event = { "order_id": "ORD002", - "destination_warehouse_id": "WH2" + "destination": {"lat": 7.2906, "lng": 80.6337} } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -38,55 +36,50 @@ def test_missing_origin_does_not_create_shipment(self): def test_missing_destination_does_not_create_shipment(self): event = { "order_id": "ORD003", - "origin_warehouse_id": "WH1" + "origin": {"lat": 6.9271, "lng": 79.8612} } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) def test_invalid_data_type_ignored(self): - """If order_id is not a string, the handler should not crash.""" event = { - "order_id": 12345, - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "order_id": 12345, # Still acceptable as string-like + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6} } handle_order_created(event) self.assertEqual(Shipment.objects.filter(order_id=12345).count(), 1) def test_duplicate_order_id_creates_separate_shipments(self): - """If shipment_id is random, even duplicate order_id can create multiple records.""" event = { "order_id": "ORDDUP", - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6} } handle_order_created(event) handle_order_created(event) self.assertEqual(Shipment.objects.filter(order_id="ORDDUP").count(), 2) def test_extra_fields_are_ignored(self): - """Extra fields in the event should not break creation.""" event = { "order_id": "ORD004", - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2", + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6}, "customer_priority": "high", - "notes": "this is ignored" + "notes": "ignored field" } handle_order_created(event) self.assertTrue(Shipment.objects.filter(order_id="ORD004").exists()) def test_empty_event_dict(self): - """An empty dict should be gracefully ignored.""" handle_order_created({}) self.assertEqual(Shipment.objects.count(), 0) def test_null_values(self): - """Null values should not create shipments.""" event = { "order_id": None, - "origin_warehouse_id": None, - "destination_warehouse_id": None, + "origin": None, + "destination": None, } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) diff --git a/shipments/tests/test_integration_kafka.py b/shipments/tests/test_integration_kafka.py index 1dee1ce..4cbd047 100644 --- a/shipments/tests/test_integration_kafka.py +++ b/shipments/tests/test_integration_kafka.py @@ -3,7 +3,6 @@ from django.test import TestCase from shipments.models import Shipment from confluent_kafka import Producer - from shipments.consumers.order_events import run_consumer_once logger = logging.getLogger(__name__) @@ -18,8 +17,8 @@ def test_order_event_creates_shipment(self): order_id = "KAFKA_E2E_01" event = { "order_id": order_id, - "origin_warehouse_id": "WH-X", - "destination_warehouse_id": "WH-Y" + "origin": {"lat": 6.9271, "lng": 79.8612}, + "destination": {"lat": 7.2906, "lng": 80.6337} } # Send Kafka message @@ -33,3 +32,6 @@ def test_order_event_creates_shipment(self): shipment = Shipment.objects.filter(order_id=order_id).first() logger.debug("Shipment: %s", shipment) self.assertIsNotNone(shipment, f"Shipment for {order_id} should exist") + self.assertEqual(shipment.origin, event["origin"]) + self.assertEqual(shipment.destination, event["destination"]) + self.assertEqual(shipment.status, "pending") From 81e106ecbcc3963c05c0479103707b92d25c9cce Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 16:19:10 +0530 Subject: [PATCH 4/8] update assignment model --- assignment/migrations/0001_initial.py | 24 +++++++++-- assignment/models.py | 11 ----- assignment/models/__init__.py | 0 assignment/models/assignment.py | 26 ++++++++++++ assignment/models/assignment_item.py | 20 +++++++++ assignment/serializers.py | 4 +- assignment/tests.py | 59 +++++++++++++++++++++------ assignment/views.py | 34 +++++++++++++-- route_optimizer/distance_matrix.py | 2 + 9 files changed, 150 insertions(+), 30 deletions(-) delete mode 100644 assignment/models.py create mode 100644 assignment/models/__init__.py create mode 100644 assignment/models/assignment.py create mode 100644 assignment/models/assignment_item.py create mode 100644 route_optimizer/distance_matrix.py diff --git a/assignment/migrations/0001_initial.py b/assignment/migrations/0001_initial.py index bd9e48d..fc299e8 100644 --- a/assignment/migrations/0001_initial.py +++ b/assignment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2 on 2025-04-23 12:51 +# Generated by Django 5.2 on 2025-05-08 10:38 import django.db.models.deletion from django.db import migrations, models @@ -9,7 +9,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('fleet', '0001_initial'), + ('fleet', '0003_remove_fuelrecord_vehicle_and_more'), + ('shipments', '0002_remove_shipment_destination_warehouse_id_and_more'), ] operations = [ @@ -18,9 +19,26 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), - ('delivery_locations', models.JSONField()), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('created', 'Created'), ('dispatched', 'Dispatched'), ('partially_completed', 'Partially Completed'), ('completed', 'Completed'), ('failed', 'Failed'), ('reassigned', 'Reassigned')], default='created', max_length=32)), ('total_load', models.PositiveIntegerField()), ('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleet.vehicle')), ], ), + migrations.CreateModel( + name='AssignmentItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('delivery_sequence', models.PositiveIntegerField()), + ('delivery_location', models.JSONField()), + ('is_delivered', models.BooleanField(default=False)), + ('delivered_at', models.DateTimeField(blank=True, null=True)), + ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='assignment.assignment')), + ('shipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shipments.shipment')), + ], + options={ + 'ordering': ['delivery_sequence'], + }, + ), ] diff --git a/assignment/models.py b/assignment/models.py deleted file mode 100644 index 0f84e86..0000000 --- a/assignment/models.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.db import models -from fleet.models import Vehicle - -class Assignment(models.Model): - vehicle = models.ForeignKey(Vehicle, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) - delivery_locations = models.JSONField() # List of [longitude, latitude] - total_load = models.PositiveIntegerField() - - def __str__(self): - return f"Assignment #{self.id} to {self.vehicle.vehicle_id}" diff --git a/assignment/models/__init__.py b/assignment/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/models/assignment.py b/assignment/models/assignment.py new file mode 100644 index 0000000..a41118a --- /dev/null +++ b/assignment/models/assignment.py @@ -0,0 +1,26 @@ +from django.db import models +from fleet.models import Vehicle + +class Assignment(models.Model): + vehicle = models.ForeignKey(Vehicle, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + status = models.CharField( + max_length=32, + choices=[ + ('created', 'Created'), + ('dispatched', 'Dispatched'), + ('partially_completed', 'Partially Completed'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ('reassigned', 'Reassigned'), + ], + default='created' + ) + + total_load = models.PositiveIntegerField() + + def __str__(self): + return f"Assignment #{self.id} to Vehicle {self.vehicle.vehicle_id}" diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py new file mode 100644 index 0000000..c649b9c --- /dev/null +++ b/assignment/models/assignment_item.py @@ -0,0 +1,20 @@ +from django.db import models + +from assignment.models.assignment import Assignment +from shipments.models import Shipment + + +class AssignmentItem(models.Model): + 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_location = models.JSONField() # { "lat": ..., "lng": ... } + + 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}" diff --git a/assignment/serializers.py b/assignment/serializers.py index cd28200..6067b65 100644 --- a/assignment/serializers.py +++ b/assignment/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers -from .models import Assignment + +from assignment.models.assignment import Assignment + class AssignmentSerializer(serializers.ModelSerializer): class Meta: diff --git a/assignment/tests.py b/assignment/tests.py index b4b1d6e..5183320 100644 --- a/assignment/tests.py +++ b/assignment/tests.py @@ -1,28 +1,51 @@ from django.test import TestCase from rest_framework.test import APIClient + +from assignment.models.assignment import Assignment +from assignment.models.assignment_item import AssignmentItem from fleet.models import Vehicle -from assignment.models import Assignment +from shipments.models import Shipment + class AssignmentAPITest(TestCase): def setUp(self): self.client = APIClient() self.vehicle = Vehicle.objects.create(vehicle_id="TRK001", capacity=100, status="available") self.busy_vehicle = Vehicle.objects.create(vehicle_id="TRK002", capacity=80, status="assigned") + self.shipment1 = Shipment.objects.create( + shipment_id="SHP001", + order_id="ORD001", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.3, "lng": 80.6}, + status="pending" + ) + self.shipment2 = Shipment.objects.create( + shipment_id="SHP002", + order_id="ORD002", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.4, "lng": 80.5}, + status="pending" + ) def test_create_assignment_success(self): payload = { "deliveries": [ - {"location": [77.59, 12.97], "load": 40}, - {"location": [77.61, 12.98], "load": 30} + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 40, "sequence": 1}, + {"shipment_id": self.shipment2.id, "location": {"lat": 7.4, "lng": 80.5}, "load": 30, "sequence": 2} ] } response = self.client.post('/api/assignment/assignments/', payload, format='json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['total_load'], 70) + assignment_id = response.data['id'] + items = AssignmentItem.objects.filter(assignment_id=assignment_id) + self.assertEqual(items.count(), 2) def test_create_assignment_insufficient_capacity(self): payload = { - "deliveries": [{"location": [77.59, 12.97], "load": 150}] + "deliveries": [ + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 150, "sequence": 1} + ] } response = self.client.post('/api/assignment/assignments/', payload, format='json') self.assertEqual(response.status_code, 400) @@ -31,7 +54,11 @@ def test_create_assignment_insufficient_capacity(self): def test_create_assignment_with_no_available_vehicle(self): self.vehicle.status = "maintenance" self.vehicle.save() - payload = {"deliveries": [{"location": [77.59, 12.97], "load": 50}]} + payload = { + "deliveries": [ + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} + ] + } response = self.client.post('/api/assignment/assignments/', payload, format='json') self.assertEqual(response.status_code, 400) @@ -42,10 +69,16 @@ def test_create_assignment_with_no_deliveries(self): self.assertIn("Deliveries required", response.data['error']) def test_get_all_assignments(self): - Assignment.objects.create( + assignment = Assignment.objects.create( vehicle=self.vehicle, - delivery_locations=[[77.59, 12.97]], - total_load=50 + total_load=50, + status='created' + ) + AssignmentItem.objects.create( + assignment=assignment, + shipment=self.shipment1, + delivery_sequence=1, + delivery_location={"lat": 7.3, "lng": 80.6} ) response = self.client.get('/api/assignment/assignments/') self.assertEqual(response.status_code, 200) @@ -53,7 +86,9 @@ def test_get_all_assignments(self): def test_vehicle_marked_assigned_after_assignment(self): payload = { - "deliveries": [{"location": [77.59, 12.97], "load": 50}] + "deliveries": [ + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} + ] } self.client.post('/api/assignment/assignments/', payload, format='json') self.vehicle.refresh_from_db() @@ -62,8 +97,8 @@ def test_vehicle_marked_assigned_after_assignment(self): def test_assignment_model_str(self): assignment = Assignment.objects.create( vehicle=self.vehicle, - delivery_locations=[[77.59, 12.97]], - total_load=50 + total_load=50, + status='created' ) - expected = f"Assignment #{assignment.id} to {self.vehicle.vehicle_id}" + expected = f"Assignment #{assignment.id} to Vehicle {self.vehicle.vehicle_id}" self.assertEqual(str(assignment), expected) diff --git a/assignment/views.py b/assignment/views.py index 2680f55..6abfff1 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -1,8 +1,12 @@ from rest_framework import viewsets, status from rest_framework.response import Response -from .models import Assignment + +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 + class AssignmentViewSet(viewsets.ModelViewSet): queryset = Assignment.objects.all() @@ -13,18 +17,42 @@ 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, - delivery_locations=[d["location"] for d in deliveries], - total_load=total_load + total_load=total_load, + status='created' ) + + # Create AssignmentItem entries + for delivery in deliveries: + shipment_id = delivery.get("shipment_id") + location = delivery.get("location") + sequence = delivery.get("sequence", 1) # fallback if sequence not provided + + try: + shipment = Shipment.objects.get(id=shipment_id) + except Shipment.DoesNotExist: + return Response({"error": f"Shipment {shipment_id} does not exist"}, status=400) + + AssignmentItem.objects.create( + assignment=assignment, + shipment=shipment, + delivery_sequence=sequence, + delivery_location=location, + ) + serializer = self.get_serializer(assignment) return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/route_optimizer/distance_matrix.py b/route_optimizer/distance_matrix.py new file mode 100644 index 0000000..303f38f --- /dev/null +++ b/route_optimizer/distance_matrix.py @@ -0,0 +1,2 @@ +def get_distance_matrix(location): + return 3 \ No newline at end of file From db4eca9151c800c883123014c58866f23072f6d2 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 16:28:50 +0530 Subject: [PATCH 5/8] Delete clients for apps in this project --- assignment/clients/fleet_client.py | 31 --------------------------- assignment/clients/shipment_client.py | 14 ------------ 2 files changed, 45 deletions(-) delete mode 100644 assignment/clients/fleet_client.py delete mode 100644 assignment/clients/shipment_client.py diff --git a/assignment/clients/fleet_client.py b/assignment/clients/fleet_client.py deleted file mode 100644 index 14884eb..0000000 --- a/assignment/clients/fleet_client.py +++ /dev/null @@ -1,31 +0,0 @@ -from fleet.models import Vehicle - - -class FleetClient: - @staticmethod - def get_available_vehicles(min_capacity=None, limit=None): - """ - fetch available vehicles, optionally filtered by capacity and limited in count. - - Args: - min_capacity (int, optional): Minimum vehicle capacity. - limit (int, optional): Maximum number of vehicles to return. - - Returns: - QuerySet of Vehicle objects - """ - qs = Vehicle.objects.filter(status='available') - if min_capacity is not None: - qs = qs.filter(capacity__gte=min_capacity) - if limit is not None: - qs = qs[:limit] - return qs - - @staticmethod - def get_vehicle_by_id(vehicle_id): - return Vehicle.objects.filter(id=vehicle_id).first() - - @staticmethod - def mark_assigned(vehicle): - vehicle.status = 'assigned' - vehicle.save(update_fields=['status']) diff --git a/assignment/clients/shipment_client.py b/assignment/clients/shipment_client.py deleted file mode 100644 index 9ac82b5..0000000 --- a/assignment/clients/shipment_client.py +++ /dev/null @@ -1,14 +0,0 @@ -from shipments.models import Shipment - -class ShipmentClient: - @staticmethod - def get_pending_shipments(): - return Shipment.objects.filter(status='pending') - - @staticmethod - def mark_scheduled(shipment, vehicle, dispatch_time=None): - shipment.status = 'scheduled' - shipment.assigned_vehicle_id = vehicle.id - if dispatch_time: - shipment.scheduled_dispatch = dispatch_time - shipment.save(update_fields=['status', 'assigned_vehicle_id', 'scheduled_dispatch']) From d3b942435b81fae4502d5477a3a8511135cc3861 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 17:01:35 +0530 Subject: [PATCH 6/8] Add demand field to shipment --- shipments/admin.py | 7 ++- shipments/consumers/order_events.py | 13 +++- shipments/migrations/0003_shipment_demand.py | 18 ++++++ shipments/models.py | 3 + shipments/tests/test_api.py | 44 +++++++------ shipments/tests/test_consumer.py | 65 +++++++++++++++----- shipments/views.py | 33 ++++++---- 7 files changed, 128 insertions(+), 55 deletions(-) create mode 100644 shipments/migrations/0003_shipment_demand.py diff --git a/shipments/admin.py b/shipments/admin.py index 30b1d50..9505d86 100644 --- a/shipments/admin.py +++ b/shipments/admin.py @@ -9,8 +9,9 @@ class ShipmentAdmin(admin.ModelAdmin): 'order_id', 'get_origin', 'get_destination', + 'demand', 'status', - 'created_at' + 'created_at', ) list_filter = ('status',) search_fields = ('shipment_id', 'order_id') @@ -18,10 +19,10 @@ class ShipmentAdmin(admin.ModelAdmin): @admin.display(description="Origin") def get_origin(self, obj): - loc = obj.origin_location + loc = obj.origin return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" @admin.display(description="Destination") def get_destination(self, obj): - loc = obj.destination_location + loc = obj.destination return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" diff --git a/shipments/consumers/order_events.py b/shipments/consumers/order_events.py index d2b1a8b..9a984ec 100644 --- a/shipments/consumers/order_events.py +++ b/shipments/consumers/order_events.py @@ -19,23 +19,30 @@ def handle_order_created(event): order_id = event.get("order_id") origin = event.get("origin") # Expecting {"lat": ..., "lng": ...} destination = event.get("destination") + demand = event.get("demand", 0) + # Basic validation if not (order_id and origin and destination): - logging.error("Invalid order event payload") + logging.error("Invalid order event payload: missing fields") return if not all(k in origin for k in ("lat", "lng")) or not all(k in destination for k in ("lat", "lng")): logging.error("Origin/destination must include lat/lng") return + if not isinstance(demand, int) or demand < 0: + logging.warning(f"Invalid or missing demand for order {order_id}. Defaulting to 0.") + demand = 0 + Shipment.objects.create( shipment_id=str(uuid.uuid4())[:12], - order_id=order_id, + order_id=str(order_id), origin=origin, destination=destination, + demand=demand, status='pending' ) - logging.info(f"Shipment created for order {order_id}") + logging.info(f"Shipment created for order {order_id} with demand {demand}") def start_order_consumer(): diff --git a/shipments/migrations/0003_shipment_demand.py b/shipments/migrations/0003_shipment_demand.py new file mode 100644 index 0000000..3c5b2a4 --- /dev/null +++ b/shipments/migrations/0003_shipment_demand.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-08 11:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipments', '0002_remove_shipment_destination_warehouse_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='shipment', + name='demand', + field=models.PositiveIntegerField(default=0, help_text='Amount of load required for this shipment (e.g., in kg or units)'), + ), + ] diff --git a/shipments/models.py b/shipments/models.py index a277e2d..ff971c8 100644 --- a/shipments/models.py +++ b/shipments/models.py @@ -2,6 +2,7 @@ from django.utils import timezone from django.core.exceptions import ValidationError + class Shipment(models.Model): STATUS_CHOICES = [ ('pending', 'Pending'), @@ -18,6 +19,8 @@ class Shipment(models.Model): origin = models.JSONField() destination = models.JSONField() + demand = models.PositiveIntegerField(help_text="Amount of load required for this shipment (e.g., in kg or units)", default=0) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') scheduled_dispatch = models.DateTimeField(null=True, blank=True) actual_dispatch = models.DateTimeField(null=True, blank=True) diff --git a/shipments/tests/test_api.py b/shipments/tests/test_api.py index cb285de..2f0c4e7 100644 --- a/shipments/tests/test_api.py +++ b/shipments/tests/test_api.py @@ -2,9 +2,8 @@ from rest_framework.test import APIClient from rest_framework import status from django.utils import timezone -from shipments.models import Shipment from datetime import timedelta -from django.core.exceptions import ValidationError +from shipments.models import Shipment class ShipmentAPITestCase(TestCase): @@ -14,26 +13,32 @@ def setUp(self): shipment_id="SHIP123", order_id="ORD456", origin={"lat": 6.9271, "lng": 79.8612}, - destination={"lat": 7.2906, "lng": 80.6337} + destination={"lat": 7.2906, "lng": 80.6337}, + demand=50, ) - def test_create_shipment(self): + def create_shipment(self, shipment_id="SHIP999", demand=75): payload = { - "shipment_id": "SHIP999", + "shipment_id": shipment_id, "order_id": "ORD999", "origin": {"lat": 6.9, "lng": 79.8}, - "destination": {"lat": 7.2, "lng": 80.6} + "destination": {"lat": 7.2, "lng": 80.6}, + "demand": demand, } - response = self.client.post("/api/shipments/", payload, format="json") - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + return self.client.post("/api/shipments/", payload, format="json") + + def test_create_shipment(self): + response = self.create_shipment() + self.assertEqual(response.status_code, status.HTTP_201_CREATED, msg=response.data) self.assertEqual(response.data["shipment_id"], "SHIP999") + self.assertEqual(response.data["demand"], 75) def test_mark_scheduled(self): scheduled_time = (timezone.now() + timedelta(days=1)).isoformat() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_scheduled/", { "scheduled_time": scheduled_time }, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "scheduled") def test_mark_dispatched(self): @@ -42,14 +47,14 @@ def test_mark_dispatched(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_dispatched/", { "dispatch_time": dispatch_time }, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "dispatched") def test_mark_in_transit(self): self.shipment.mark_scheduled() self.shipment.mark_dispatched() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_in_transit/") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "in_transit") def test_mark_delivered(self): @@ -60,17 +65,17 @@ def test_mark_delivered(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_delivered/", { "delivery_time": delivery_time }, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "delivered") def test_mark_failed(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_failed/") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "failed") def test_invalid_transition_dispatched_without_schedule(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_dispatched/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_invalid_transition_delivered_without_in_transit(self): @@ -79,7 +84,7 @@ def test_invalid_transition_delivered_without_in_transit(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_delivered/", { "delivery_time": timezone.now().isoformat() }, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_invalid_transition_failed_after_delivery(self): @@ -88,20 +93,20 @@ def test_invalid_transition_failed_after_delivery(self): self.shipment.mark_in_transit() self.shipment.mark_delivered() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_failed/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_revert_to_pending_from_scheduled(self): self.shipment.mark_scheduled() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_pending/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "pending") def test_invalid_revert_to_pending_from_dispatched(self): self.shipment.mark_scheduled() self.shipment.mark_dispatched() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_pending/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_duplicate_shipment_id(self): @@ -110,5 +115,6 @@ def test_duplicate_shipment_id(self): shipment_id="SHIP123", order_id="ORD999", origin={"lat": 1.0, "lng": 2.0}, - destination={"lat": 3.0, "lng": 4.0} + destination={"lat": 3.0, "lng": 4.0}, + demand=20, ) diff --git a/shipments/tests/test_consumer.py b/shipments/tests/test_consumer.py index 54de5c9..6efdc36 100644 --- a/shipments/tests/test_consumer.py +++ b/shipments/tests/test_consumer.py @@ -8,19 +8,22 @@ def test_valid_order_event_creates_shipment(self): event = { "order_id": "ORD001", "origin": {"lat": 6.9271, "lng": 79.8612}, - "destination": {"lat": 7.2906, "lng": 80.6337} + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 25 } handle_order_created(event) shipment = Shipment.objects.get(order_id="ORD001") self.assertEqual(shipment.status, "pending") - self.assertEqual(shipment.origin, {"lat": 6.9271, "lng": 79.8612}) - self.assertEqual(shipment.destination, {"lat": 7.2906, "lng": 80.6337}) + self.assertEqual(shipment.origin, event["origin"]) + self.assertEqual(shipment.destination, event["destination"]) + self.assertEqual(shipment.demand, 25) def test_missing_order_id_does_not_create_shipment(self): event = { "origin": {"lat": 6.9271, "lng": 79.8612}, - "destination": {"lat": 7.2906, "lng": 80.6337} + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -28,7 +31,8 @@ def test_missing_order_id_does_not_create_shipment(self): def test_missing_origin_does_not_create_shipment(self): event = { "order_id": "ORD002", - "destination": {"lat": 7.2906, "lng": 80.6337} + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -36,50 +40,77 @@ def test_missing_origin_does_not_create_shipment(self): def test_missing_destination_does_not_create_shipment(self): event = { "order_id": "ORD003", - "origin": {"lat": 6.9271, "lng": 79.8612} + "origin": {"lat": 6.9271, "lng": 79.8612}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) - def test_invalid_data_type_ignored(self): + def test_invalid_data_type_for_order_id_is_casted(self): event = { - "order_id": 12345, # Still acceptable as string-like + "order_id": 12345, "origin": {"lat": 6.9, "lng": 79.8}, - "destination": {"lat": 7.3, "lng": 80.6} + "destination": {"lat": 7.3, "lng": 80.6}, + "demand": 40 } handle_order_created(event) - self.assertEqual(Shipment.objects.filter(order_id=12345).count(), 1) + self.assertTrue(Shipment.objects.filter(order_id=str(12345)).exists()) - def test_duplicate_order_id_creates_separate_shipments(self): + def test_duplicate_order_id_creates_multiple_shipments(self): event = { "order_id": "ORDDUP", "origin": {"lat": 6.9, "lng": 79.8}, - "destination": {"lat": 7.3, "lng": 80.6} + "destination": {"lat": 7.3, "lng": 80.6}, + "demand": 50 } handle_order_created(event) handle_order_created(event) self.assertEqual(Shipment.objects.filter(order_id="ORDDUP").count(), 2) - def test_extra_fields_are_ignored(self): + def test_extra_fields_are_ignored_and_demand_saved(self): event = { "order_id": "ORD004", "origin": {"lat": 6.9, "lng": 79.8}, "destination": {"lat": 7.3, "lng": 80.6}, "customer_priority": "high", - "notes": "ignored field" + "notes": "this should be ignored", + "demand": 60 } handle_order_created(event) - self.assertTrue(Shipment.objects.filter(order_id="ORD004").exists()) + shipment = Shipment.objects.get(order_id="ORD004") + self.assertEqual(shipment.demand, 60) - def test_empty_event_dict(self): + def test_event_with_no_fields_does_nothing(self): handle_order_created({}) self.assertEqual(Shipment.objects.count(), 0) - def test_null_values(self): + def test_null_values_are_ignored(self): event = { "order_id": None, "origin": None, "destination": None, + "demand": None } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) + + def test_negative_demand_defaults_to_zero(self): + event = { + "order_id": "ORD_NEG", + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6}, + "demand": -5 + } + handle_order_created(event) + shipment = Shipment.objects.get(order_id="ORD_NEG") + self.assertEqual(shipment.demand, 0) + + def test_missing_demand_defaults_to_zero(self): + event = { + "order_id": "ORD_NO_DEMAND", + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6} + } + handle_order_created(event) + shipment = Shipment.objects.get(order_id="ORD_NO_DEMAND") + self.assertEqual(shipment.demand, 0) diff --git a/shipments/views.py b/shipments/views.py index a7f46d9..ad0ead6 100644 --- a/shipments/views.py +++ b/shipments/views.py @@ -3,6 +3,7 @@ from rest_framework.response import Response from django.core.exceptions import ValidationError from django.utils.dateparse import parse_datetime +from django.shortcuts import get_object_or_404 from .models import Shipment from .serializers import ShipmentSerializer @@ -11,49 +12,55 @@ class ShipmentViewSet(viewsets.ModelViewSet): queryset = Shipment.objects.all() serializer_class = ShipmentSerializer - filterset_fields = ['status', 'order_id', 'origin_warehouse_id', 'destination_warehouse_id'] + filterset_fields = ['status', 'order_id'] search_fields = ['shipment_id', 'order_id'] ordering_fields = ['created_at', 'scheduled_dispatch'] def handle_transition(self, request, shipment, transition_func, time_field=None): + """ + Wrapper for status transition methods with optional timestamp support. + """ try: - # If time is required, parse it + timestamp = None if time_field: - raw_value = request.data.get(time_field) - timestamp = parse_datetime(raw_value) if raw_value else None - transition_func(timestamp) - else: - transition_func() + raw = request.data.get(time_field) + if raw: + timestamp = parse_datetime(raw) + if not timestamp: + return Response({time_field: "Invalid datetime format."}, status=400) + transition_func(timestamp) if timestamp else transition_func() return Response(self.get_serializer(shipment).data) except ValidationError as e: return Response({'error': e.message}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @action(detail=True, methods=['post']) def mark_pending(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_pending) @action(detail=True, methods=['post']) def mark_scheduled(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_scheduled, time_field='scheduled_time') @action(detail=True, methods=['post']) def mark_dispatched(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_dispatched, time_field='dispatch_time') @action(detail=True, methods=['post']) def mark_in_transit(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_in_transit) @action(detail=True, methods=['post']) def mark_delivered(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_delivered, time_field='delivery_time') @action(detail=True, methods=['post']) def mark_failed(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_failed) From 863361ed2b18007df1f52b17690235e3690dc589 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 17:19:38 +0530 Subject: [PATCH 7/8] Add depot fields to vehicle model --- fleet/admin.py | 24 ++- fleet/migrations/0004_vehicle_depot_id.py | 18 ++ ..._depot_latitude_vehicle_depot_longitude.py | 23 +++ fleet/models/core.py | 9 + fleet/serializers/vehicle.py | 24 ++- fleet/tests/test_vehicle.py | 58 +++--- fleet/tests/test_vehicle_api.py | 98 +++++---- fleet/views/vehicle.py | 186 +++++++++--------- 8 files changed, 256 insertions(+), 184 deletions(-) create mode 100644 fleet/migrations/0004_vehicle_depot_id.py create mode 100644 fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py diff --git a/fleet/admin.py b/fleet/admin.py index b2f08df..8af2ac4 100644 --- a/fleet/admin.py +++ b/fleet/admin.py @@ -4,30 +4,40 @@ @admin.register(Vehicle) class VehicleAdmin(admin.ModelAdmin): - list_display = ('vehicle_id', 'name', 'capacity', 'status', 'fuel_type', 'last_location_update') + list_display = ( + 'vehicle_id', 'name', 'capacity', 'status', 'fuel_type', + 'depot_id', 'depot_latitude', 'depot_longitude', 'last_location_update' + ) list_filter = ('status', 'fuel_type') - search_fields = ('vehicle_id', 'name', 'plate_number') + search_fields = ('vehicle_id', 'name', 'plate_number', 'depot_id') readonly_fields = ('created_at', 'updated_at', 'last_location_update') + fieldsets = ( ('Basic Information', { - 'fields': ('vehicle_id', 'name', 'plate_number', 'year_of_manufacture') + 'fields': ( + 'vehicle_id', 'name', 'plate_number', + 'year_of_manufacture', 'status' + ) + }), + ('Depot Assignment', { + 'fields': ('depot_id', 'depot_latitude', 'depot_longitude') }), ('Specifications', { 'fields': ('capacity', 'fuel_type', 'max_speed', 'fuel_efficiency') }), - ('Status & Location', { - 'fields': ('status', 'current_latitude', 'current_longitude', 'last_location_update') + ('Current Location', { + 'fields': ('current_latitude', 'current_longitude', 'last_location_update') }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) - }) + }), ) @admin.register(VehicleLocation) class VehicleLocationAdmin(admin.ModelAdmin): - list_display = ('vehicle', 'timestamp', 'latitude', 'longitude', 'speed') + list_display = ('vehicle', 'timestamp', 'latitude', 'longitude', 'speed', 'heading') list_filter = ('timestamp',) search_fields = ('vehicle__vehicle_id',) raw_id_fields = ('vehicle',) diff --git a/fleet/migrations/0004_vehicle_depot_id.py b/fleet/migrations/0004_vehicle_depot_id.py new file mode 100644 index 0000000..3bffcc1 --- /dev/null +++ b/fleet/migrations/0004_vehicle_depot_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-08 11:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0003_remove_fuelrecord_vehicle_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='depot_id', + field=models.CharField(blank=True, help_text='External ID of the depot this vehicle is assigned to', max_length=64, null=True), + ), + ] diff --git a/fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py b/fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py new file mode 100644 index 0000000..cf6ab1b --- /dev/null +++ b/fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2025-05-08 11:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0004_vehicle_depot_id'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='depot_latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='vehicle', + name='depot_longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/fleet/models/core.py b/fleet/models/core.py index adfe6e1..0b7675b 100644 --- a/fleet/models/core.py +++ b/fleet/models/core.py @@ -28,6 +28,15 @@ class Vehicle(models.Model): plate_number = models.CharField(max_length=20, blank=True) year_of_manufacture = models.PositiveIntegerField(null=True, blank=True) + # Depot + depot_id = models.CharField( + max_length=64, + null=True, + blank=True, + help_text="External ID of the depot this vehicle is assigned to" + ) + depot_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + depot_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) # Location tracking current_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) current_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) diff --git a/fleet/serializers/vehicle.py b/fleet/serializers/vehicle.py index 3e10e6a..292fdad 100644 --- a/fleet/serializers/vehicle.py +++ b/fleet/serializers/vehicle.py @@ -9,14 +9,30 @@ class VehicleSerializer(serializers.ModelSerializer): class Meta: model = Vehicle fields = [ - 'id', 'vehicle_id', 'name', 'capacity', 'status', 'fuel_type', - 'plate_number', 'year_of_manufacture', 'current_latitude', - 'current_longitude', 'last_location_update', 'max_speed', - 'fuel_efficiency', 'created_at', 'updated_at', 'is_available', + 'id', + 'vehicle_id', + 'name', + 'capacity', + 'status', + 'fuel_type', + 'plate_number', + 'year_of_manufacture', + 'depot_id', + 'depot_latitude', + 'depot_longitude', + 'current_latitude', + 'current_longitude', + 'last_location_update', + 'max_speed', + 'fuel_efficiency', + 'created_at', + 'updated_at', + 'is_available', 'location_is_stale' ] read_only_fields = ['created_at', 'updated_at', 'last_location_update'] + class VehicleLocationSerializer(serializers.ModelSerializer): class Meta: model = VehicleLocation diff --git a/fleet/tests/test_vehicle.py b/fleet/tests/test_vehicle.py index 9807040..932d803 100644 --- a/fleet/tests/test_vehicle.py +++ b/fleet/tests/test_vehicle.py @@ -1,11 +1,10 @@ from django.test import TestCase -from rest_framework.test import APIClient - from fleet.models import Vehicle +from django.utils import timezone class VehicleModelTest(TestCase): - """Test vehicle model functionality.""" + """Unit tests for the Vehicle model.""" def setUp(self): self.vehicle = Vehicle.objects.create( @@ -19,29 +18,38 @@ def setUp(self): fuel_efficiency=8.5 ) - def test_vehicle_creation(self): - """Test that vehicle can be created.""" - self.assertEqual(self.vehicle.vehicle_id, "TRK001") - self.assertEqual(self.vehicle.capacity, 1000) - self.assertEqual(self.vehicle.status, "available") - self.assertTrue(self.vehicle.is_available) - - def test_update_location(self): - """Test updating vehicle location.""" - # Initial location should be None - self.assertIsNone(self.vehicle.current_latitude) - self.assertIsNone(self.vehicle.current_longitude) - - # Update location - latitude = 45.123456 - longitude = -75.654321 + def test_vehicle_fields_and_defaults(self): + """Test vehicle creation and default values.""" + v = self.vehicle + self.assertEqual(v.vehicle_id, "TRK001") + self.assertEqual(v.name, "Test Truck 1") + self.assertEqual(v.capacity, 1000) + self.assertEqual(v.status, "available") + self.assertEqual(v.fuel_type, "diesel") + self.assertTrue(v.is_available) + self.assertIsNone(v.current_latitude) + self.assertIsNone(v.current_longitude) + self.assertIsNone(v.last_location_update) + + def test_update_location_sets_values_and_timestamp(self): + """Test updating vehicle's current location.""" + lat, lon = 45.123456, -75.654321 + + self.vehicle.update_location(lat, lon) + self.vehicle.refresh_from_db() + + self.assertEqual(float(self.vehicle.current_latitude), lat) + self.assertEqual(float(self.vehicle.current_longitude), lon) + self.assertIsNotNone(self.vehicle.last_location_update) - self.vehicle.update_location(latitude, longitude) + now = timezone.now() + self.assertLess(abs((now - self.vehicle.last_location_update).total_seconds()), 5) - # Check that location was updated - self.assertEqual(float(self.vehicle.current_latitude), latitude) - self.assertEqual(float(self.vehicle.current_longitude), longitude) - self.assertIsNotNone(self.vehicle.last_location_update) + def test_location_is_stale_logic(self): + """Test location_is_stale property.""" + # Initially: no location update → should be stale + self.assertTrue(self.vehicle.location_is_stale) - # Check that location isn't stale right after update + # After updating location → should not be stale + self.vehicle.update_location(10.0, 20.0) self.assertFalse(self.vehicle.location_is_stale) diff --git a/fleet/tests/test_vehicle_api.py b/fleet/tests/test_vehicle_api.py index 70bc71c..17f86a1 100644 --- a/fleet/tests/test_vehicle_api.py +++ b/fleet/tests/test_vehicle_api.py @@ -1,88 +1,87 @@ from rest_framework.test import APIClient from django.test import TestCase from rest_framework import status - from fleet.models import Vehicle, VehicleLocation class VehicleAPITest(TestCase): - """Test vehicle API endpoints.""" + """Integration tests for Vehicle API endpoints.""" def setUp(self): self.client = APIClient() self.vehicle1 = Vehicle.objects.create( - vehicle_id="TRK001", capacity=1000, status="available", - name="Truck 1", fuel_type="diesel" + vehicle_id="TRK001", name="Truck 1", capacity=1000, + status="available", fuel_type="diesel" ) self.vehicle2 = Vehicle.objects.create( - vehicle_id="TRK002", capacity=500, status="maintenance", - name="Truck 2", fuel_type="petrol" + vehicle_id="TRK002", name="Truck 2", capacity=500, + status="maintenance", fuel_type="petrol" ) self.vehicle3 = Vehicle.objects.create( - vehicle_id="TRK003", capacity=750, status="assigned", - name="Truck 3", fuel_type="diesel" + vehicle_id="TRK003", name="Truck 3", capacity=750, + status="assigned", fuel_type="diesel" ) - def test_list_all_vehicles(self): - """Test retrieving all vehicles.""" + def test_get_all_vehicles(self): + """GET /api/fleet/vehicles/ should return all vehicles.""" 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): - """Test filtering vehicles by status.""" - response = self.client.get('/api/fleet/vehicles/?status=available') + def test_filter_vehicles_by_status(self): + """GET /api/fleet/vehicles/?status=available should return only available vehicles.""" + response = self.client.get('/api/fleet/vehicles/', {'status': 'available'}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['vehicle_id'], 'TRK001') + self.assertEqual(response.data[0]['vehicle_id'], "TRK001") - def test_filter_by_min_capacity(self): - """Test filtering vehicles by minimum capacity.""" - response = self.client.get('/api/fleet/vehicles/?min_capacity=800') + def test_filter_vehicles_by_min_capacity(self): + """GET /api/fleet/vehicles/?min_capacity=800 should return vehicles with capacity >= 800.""" + response = self.client.get('/api/fleet/vehicles/', {'min_capacity': 800}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['vehicle_id'], 'TRK001') + self.assertEqual(response.data[0]['vehicle_id'], "TRK001") - def test_filter_by_fuel_type(self): - """Test filtering vehicles by fuel type.""" - response = self.client.get('/api/fleet/vehicles/?fuel_type=diesel') + def test_filter_vehicles_by_fuel_type(self): + """GET /api/fleet/vehicles/?fuel_type=diesel should return vehicles with diesel fuel.""" + response = self.client.get('/api/fleet/vehicles/', {'fuel_type': 'diesel'}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 2) vehicle_ids = [v['vehicle_id'] for v in response.data] - self.assertIn('TRK001', vehicle_ids) - self.assertIn('TRK003', vehicle_ids) + self.assertIn("TRK001", vehicle_ids) + self.assertIn("TRK003", vehicle_ids) - def test_create_vehicle(self): - """Test creating a new vehicle.""" + def test_create_vehicle_successfully(self): + """POST /api/fleet/vehicles/ should create a new vehicle.""" payload = { - 'vehicle_id': 'TRK004', - 'name': 'Truck 4', - 'capacity': 1200, - 'status': 'available', - 'fuel_type': 'electric', - 'plate_number': 'XYZ789' + "vehicle_id": "TRK004", + "name": "Truck 4", + "capacity": 1200, + "status": "available", + "fuel_type": "electric", + "plate_number": "XYZ789" } response = self.client.post('/api/fleet/vehicles/', payload, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.data['vehicle_id'], 'TRK004') - self.assertEqual(response.data['fuel_type'], 'electric') + self.assertEqual(response.data["vehicle_id"], "TRK004") + self.assertEqual(response.data["fuel_type"], "electric") - def test_update_vehicle(self): - """Test updating an existing vehicle.""" + def test_patch_update_vehicle_status(self): + """PATCH /api/fleet/vehicles/{id}/ should update vehicle status.""" response = self.client.patch( f'/api/fleet/vehicles/{self.vehicle1.id}/', - {'status': 'maintenance'}, + {"status": "maintenance"}, format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['status'], 'maintenance') + self.assertEqual(response.data["status"], "maintenance") - def test_update_location(self): - """Test updating vehicle location.""" + def test_update_vehicle_location(self): + """POST /api/fleet/vehicles/{id}/update_location/ should update location and create history.""" payload = { - 'latitude': 42.123456, - 'longitude': -71.654321, - 'speed': 65.5 + "latitude": 42.123456, + "longitude": -71.654321, + "speed": 65.5 } response = self.client.post( f'/api/fleet/vehicles/{self.vehicle1.id}/update_location/', @@ -90,13 +89,12 @@ def test_update_location(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - - # Check that location was updated in the vehicle self.vehicle1.refresh_from_db() - self.assertEqual(float(self.vehicle1.current_latitude), 42.123456) - self.assertEqual(float(self.vehicle1.current_longitude), -71.654321) + self.assertAlmostEqual(float(self.vehicle1.current_latitude), 42.123456) + self.assertAlmostEqual(float(self.vehicle1.current_longitude), -71.654321) - # Check that a location history record was created - location_history = VehicleLocation.objects.filter(vehicle=self.vehicle1) - self.assertEqual(location_history.count(), 1) - self.assertEqual(float(location_history[0].speed), 65.5) + # Verify historical tracking + history = VehicleLocation.objects.filter(vehicle=self.vehicle1) + self.assertEqual(history.count(), 1) + self.assertAlmostEqual(float(history[0].speed), 65.5) + self.assertAlmostEqual(float(history[0].latitude), 42.123456) diff --git a/fleet/views/vehicle.py b/fleet/views/vehicle.py index 1592168..1ece585 100644 --- a/fleet/views/vehicle.py +++ b/fleet/views/vehicle.py @@ -3,16 +3,16 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logistics_core.settings') django.setup() + from django.db.models import Sum, Count from django.utils import timezone from rest_framework import viewsets, status, filters from rest_framework.decorators import action from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend -from datetime import datetime, timedelta +from datetime import datetime from fleet.models import Vehicle, VehicleLocation - from django.conf import settings if settings.ENABLE_FLEET_EXTENDED_MODELS: @@ -28,8 +28,8 @@ class VehicleViewSet(viewsets.ModelViewSet): queryset = Vehicle.objects.all() serializer_class = VehicleSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - filterset_fields = ['status', 'fuel_type'] - search_fields = ['vehicle_id', 'name', 'plate_number'] + filterset_fields = ['status', 'fuel_type', 'depot_id'] + search_fields = ['vehicle_id', 'name', 'plate_number', 'depot_id'] ordering_fields = ['vehicle_id', 'capacity', 'status', 'created_at'] ordering = ['vehicle_id'] @@ -40,164 +40,133 @@ def get_serializer_class(self): def get_queryset(self): queryset = super().get_queryset() - request = self.request # DRF Request, safe to use .query_params - - status = request.query_params.get('status') - min_capacity = request.query_params.get('min_capacity') - max_capacity = request.query_params.get('max_capacity') - available_only = request.query_params.get('available') == 'true' + params = self.request.query_params - if status: + if status := params.get('status'): queryset = queryset.filter(status=status) - if min_capacity: + if min_cap := params.get('min_capacity'): try: - min_capacity = int(min_capacity) - queryset = queryset.filter(capacity__gte=min_capacity) + queryset = queryset.filter(capacity__gte=int(min_cap)) except ValueError: - pass # Ignore invalid capacity filters - if max_capacity: + pass + if max_cap := params.get('max_capacity'): try: - max_capacity = int(max_capacity) - queryset = queryset.filter(capacity__lte=max_capacity) + queryset = queryset.filter(capacity__lte=int(max_cap)) except ValueError: pass - if available_only: + if params.get('available') == 'true': queryset = queryset.filter(status='available') + if depot := params.get('depot_id'): + queryset = queryset.filter(depot_id=depot) return queryset @action(detail=True, methods=['post']) def update_location(self, request, pk=None): - """ - Update vehicle location. - POST /api/fleet/vehicles/{id}/update_location/ - """ vehicle = self.get_object() - - # Extract location data from request latitude = request.data.get('latitude') longitude = request.data.get('longitude') speed = request.data.get('speed') heading = request.data.get('heading') - # Validate required fields - if not latitude or not longitude: - return Response( - {'error': 'Latitude and longitude are required'}, - status=status.HTTP_400_BAD_REQUEST - ) + if latitude is None or longitude is None: + return Response({'error': 'Latitude and longitude are required'}, status=400) try: - # Update current location on vehicle vehicle.update_location(latitude, longitude) - - # Create a location history record - location_data = { - 'vehicle': vehicle, - 'latitude': latitude, - 'longitude': longitude - } - - if speed is not None: - location_data['speed'] = speed - if heading is not None: - location_data['heading'] = heading - - VehicleLocation.objects.create(**location_data) - - return Response({'status': 'location updated'}, status=status.HTTP_200_OK) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST + VehicleLocation.objects.create( + vehicle=vehicle, + latitude=latitude, + longitude=longitude, + speed=speed or None, + heading=heading or None ) + return Response({'status': 'location updated'}, status=200) + except Exception as e: + return Response({'error': str(e)}, status=400) @action(detail=True, methods=['post']) def change_status(self, request, pk=None): - """ - Change vehicle status. - POST /api/fleet/vehicles/{id}/change_status/ - """ vehicle = self.get_object() new_status = request.data.get('status') if not new_status: - return Response( - {'error': 'Status is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({'error': 'Status is required'}, status=400) - # Validate status choice if new_status not in dict(Vehicle.STATUS_CHOICES): - return Response( - {'error': f'Invalid status. Must be one of: {dict(Vehicle.STATUS_CHOICES).keys()}'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({'error': f'Invalid status: {new_status}'}, status=400) - # Handle status change to maintenance - if new_status == 'maintenance' and vehicle.status != 'maintenance': - # Optionally create a maintenance record + 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() - # Create maintenance record MaintenanceRecord.objects.create( vehicle=vehicle, maintenance_type=maintenance_type, description=description, scheduled_date=scheduled_date, - status='in_progress' # Since we're changing status to maintenance now + status='in_progress' ) - # Update vehicle status 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): + """ + Assign or update a vehicle's depot. + POST /api/fleet/vehicles/{id}/assign_depot/ + { + "depot_id": "WHS001", + "latitude": 6.9271, + "longitude": 79.8612 + } + """ + vehicle = self.get_object() + depot_id = request.data.get('depot_id') + depot_lat = request.data.get('latitude') + depot_lon = request.data.get('longitude') + + if depot_id is None: + return Response({'error': 'depot_id is required'}, status=400) + + vehicle.depot_id = depot_id + if depot_lat is not None and depot_lon is not None: + try: + vehicle.depot_latitude = float(depot_lat) + vehicle.depot_longitude = float(depot_lon) + except ValueError: + return Response({'error': 'Invalid latitude or longitude'}, status=400) + + vehicle.save(update_fields=['depot_id', 'depot_latitude', 'depot_longitude', 'updated_at']) return Response(VehicleSerializer(vehicle).data) @action(detail=False, methods=['get']) def stats(self, request): - """ - Get fleet statistics. - GET /api/fleet/vehicles/stats/ - """ - # Count vehicles by status status_counts = dict( Vehicle.objects.values('status').annotate(count=Count('id')).values_list('status', 'count') ) + for s, _ in Vehicle.STATUS_CHOICES: + status_counts.setdefault(s, 0) - # Fill in missing statuses with 0 - for status, _ in Vehicle.STATUS_CHOICES: - if status not in status_counts: - status_counts[status] = 0 - - # Count total vehicles total_vehicles = Vehicle.objects.count() - - # Calculate total fleet capacity total_capacity = Vehicle.objects.aggregate(Sum('capacity'))['capacity__sum'] or 0 + available_capacity = Vehicle.objects.filter(status='available').aggregate(Sum('capacity'))['capacity__sum'] or 0 + maintenance_count = 0 - # Calculate available capacity - available_capacity = Vehicle.objects.filter(status='available').aggregate( - Sum('capacity') - )['capacity__sum'] or 0 - - # Calculate maintenance stats - maintenance_count = MaintenanceRecord.objects.filter( - status__in=['scheduled', 'in_progress'] - ).count() + if settings.ENABLE_FLEET_EXTENDED_MODELS: + maintenance_count = MaintenanceRecord.objects.filter(status__in=['scheduled', 'in_progress']).count() - # Current utilization rate utilization_rate = 0 - if total_vehicles > 0: - assigned_count = status_counts.get('assigned', 0) - utilization_rate = (assigned_count / total_vehicles) * 100 + if total_vehicles: + utilization_rate = (status_counts.get('assigned', 0) / total_vehicles) * 100 return Response({ 'total_vehicles': total_vehicles, @@ -207,3 +176,24 @@ def stats(self, request): 'maintenance_count': maintenance_count, 'utilization_rate': utilization_rate }) + + @action(detail=False, methods=['get']) + def by_depot(self, request): + depot_id = request.query_params.get('depot_id') + if not depot_id: + return Response({'error': 'Missing depot_id parameter'}, status=400) + + vehicles = Vehicle.objects.filter(depot_id=depot_id) + return Response(VehicleSerializer(vehicles, many=True).data) + + @action(detail=False, methods=['get']) + def depot_stats(self, request): + """ + Returns count and total capacity of vehicles per depot. + """ + stats = Vehicle.objects.values('depot_id').annotate( + count=Count('id'), + total_capacity=Sum('capacity') + ).order_by('depot_id') + + return Response({'by_depot': stats}) From 55612394ef54a1c665bb1739f031b532b42e1aeb Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 20:10:33 +0530 Subject: [PATCH 8/8] Assignment service --- assignment/services/__init__.py | 0 assignment/services/assignment_planner.py | 91 +++++++++++++++ assignment/services/mappers.py | 14 +++ assignment/tests.py | 104 ----------------- assignment/tests/__init__.py | 0 assignment/tests/test_assignment_planner.py | 118 ++++++++++++++++++++ route_optimizer/models/vrp_input.py | 2 + 7 files changed, 225 insertions(+), 104 deletions(-) create mode 100644 assignment/services/__init__.py create mode 100644 assignment/services/assignment_planner.py create mode 100644 assignment/services/mappers.py delete mode 100644 assignment/tests.py create mode 100644 assignment/tests/__init__.py create mode 100644 assignment/tests/test_assignment_planner.py diff --git a/assignment/services/__init__.py b/assignment/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py new file mode 100644 index 0000000..f333d0f --- /dev/null +++ b/assignment/services/assignment_planner.py @@ -0,0 +1,91 @@ +import logging +from typing import List +from datetime import datetime + +from assignment.models.assignment import Assignment +from assignment.models.assignment_item import AssignmentItem +from assignment.services.mappers import map_vehicle_model +from fleet.models import Vehicle +from route_optimizer.services.vrp_solver import solve_cvrp +from shipments.models import Shipment +from route_optimizer.models.vrp_input import VRPInputBuilder, VRPCompiler, Location, DeliveryTask + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +class AssignmentPlanner: + def __init__(self, vehicles: List[Vehicle], shipments: List[Shipment]): + self.vehicles = vehicles + self.shipments = shipments + + def plan_assignments(self) -> List[Assignment]: + logger.info("Planning assignments started.") + builder = VRPInputBuilder() + + vehicle_map = {} + for v in self.vehicles: + logger.debug(f"Mapping vehicle: {v.vehicle_id}") + mapped_vehicle = map_vehicle_model(v) + builder.add_vehicle(mapped_vehicle) + vehicle_map[mapped_vehicle.id] = v + logger.info(f"{len(vehicle_map)} vehicles added to VRP input.") + + shipment_map = {} + for s in self.shipments: + logger.debug(f"Adding shipment: {s.shipment_id} (demand={s.demand})") + builder.add_delivery_task( + DeliveryTask( + id=str(s.id), + pickup=Location(lat=s.origin["lat"], lon=s.origin["lng"]), + delivery=Location(lat=s.destination["lat"], lon=s.destination["lng"]), + demand=s.demand, + ) + ) + shipment_map[str(s.id)] = s + logger.info(f"{len(shipment_map)} shipments added to VRP input.") + + vrp_input = VRPCompiler.compile(builder) + logger.debug(f"Compiled VRP input with {len(vrp_input.location_ids)} locations.") + + result = solve_cvrp(vrp_input) + logger.info("Optimizer finished solving.") + + if result["status"] != "success": + logger.error("Optimizer failed to find a solution.") + raise Exception("Optimization failed") + + assignments = [] + for i, route in enumerate(result["routes"]): + vehicle = self.vehicles[i] + logger.debug(f"Creating assignment for vehicle {vehicle.vehicle_id}, route: {route}") + assignment = Assignment.objects.create( + vehicle=vehicle, + total_load=sum( + vrp_input.demands[node] for node in route if vrp_input.demands[node] > 0 + ), + status='created' + ) + + seq = 1 + for node in route: + if node in vrp_input.task_index_map: + task_id, role = vrp_input.task_index_map[node] + shipment = shipment_map[task_id] + loc = shipment.destination if role == "delivery" else shipment.origin + + logger.debug(f"Adding {role} for shipment {shipment.shipment_id} at sequence {seq}") + AssignmentItem.objects.create( + assignment=assignment, + shipment=shipment, + delivery_sequence=seq, + delivery_location={ + "lat": loc["lat"], + "lng": loc["lng"], + } + ) + seq += 1 + + assignments.append(assignment) + + logger.info(f"{len(assignments)} assignments successfully created.") + return assignments diff --git a/assignment/services/mappers.py b/assignment/services/mappers.py new file mode 100644 index 0000000..1f187c7 --- /dev/null +++ b/assignment/services/mappers.py @@ -0,0 +1,14 @@ +from route_optimizer.models.vrp_input import Vehicle as VRPVehicle, Location + +def map_vehicle_model(vehicle_model): + if vehicle_model.depot_latitude is None or vehicle_model.depot_longitude is None: + raise ValueError(f"Vehicle {vehicle_model.vehicle_id} missing depot coordinates") + + return VRPVehicle( + id=vehicle_model.vehicle_id, + capacity=vehicle_model.capacity, + depot=Location( + lat=float(vehicle_model.depot_latitude), + lon=float(vehicle_model.depot_longitude) + ) + ) diff --git a/assignment/tests.py b/assignment/tests.py deleted file mode 100644 index 5183320..0000000 --- a/assignment/tests.py +++ /dev/null @@ -1,104 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIClient - -from assignment.models.assignment import Assignment -from assignment.models.assignment_item import AssignmentItem -from fleet.models import Vehicle -from shipments.models import Shipment - - -class AssignmentAPITest(TestCase): - def setUp(self): - self.client = APIClient() - self.vehicle = Vehicle.objects.create(vehicle_id="TRK001", capacity=100, status="available") - self.busy_vehicle = Vehicle.objects.create(vehicle_id="TRK002", capacity=80, status="assigned") - self.shipment1 = Shipment.objects.create( - shipment_id="SHP001", - order_id="ORD001", - origin={"lat": 6.9, "lng": 79.8}, - destination={"lat": 7.3, "lng": 80.6}, - status="pending" - ) - self.shipment2 = Shipment.objects.create( - shipment_id="SHP002", - order_id="ORD002", - origin={"lat": 6.9, "lng": 79.8}, - destination={"lat": 7.4, "lng": 80.5}, - status="pending" - ) - - def test_create_assignment_success(self): - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 40, "sequence": 1}, - {"shipment_id": self.shipment2.id, "location": {"lat": 7.4, "lng": 80.5}, "load": 30, "sequence": 2} - ] - } - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['total_load'], 70) - assignment_id = response.data['id'] - items = AssignmentItem.objects.filter(assignment_id=assignment_id) - self.assertEqual(items.count(), 2) - - def test_create_assignment_insufficient_capacity(self): - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 150, "sequence": 1} - ] - } - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 400) - self.assertIn("No available vehicle", response.data['error']) - - def test_create_assignment_with_no_available_vehicle(self): - self.vehicle.status = "maintenance" - self.vehicle.save() - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} - ] - } - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 400) - - def test_create_assignment_with_no_deliveries(self): - payload = {} - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 400) - self.assertIn("Deliveries required", response.data['error']) - - def test_get_all_assignments(self): - assignment = Assignment.objects.create( - vehicle=self.vehicle, - total_load=50, - status='created' - ) - AssignmentItem.objects.create( - assignment=assignment, - shipment=self.shipment1, - delivery_sequence=1, - delivery_location={"lat": 7.3, "lng": 80.6} - ) - response = self.client.get('/api/assignment/assignments/') - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - - def test_vehicle_marked_assigned_after_assignment(self): - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} - ] - } - self.client.post('/api/assignment/assignments/', payload, format='json') - self.vehicle.refresh_from_db() - self.assertEqual(self.vehicle.status, "assigned") - - def test_assignment_model_str(self): - assignment = Assignment.objects.create( - vehicle=self.vehicle, - total_load=50, - status='created' - ) - expected = f"Assignment #{assignment.id} to Vehicle {self.vehicle.vehicle_id}" - self.assertEqual(str(assignment), expected) diff --git a/assignment/tests/__init__.py b/assignment/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/tests/test_assignment_planner.py b/assignment/tests/test_assignment_planner.py new file mode 100644 index 0000000..de7f2cf --- /dev/null +++ b/assignment/tests/test_assignment_planner.py @@ -0,0 +1,118 @@ +from django.test import TestCase +from assignment.services.assignment_planner import AssignmentPlanner +from fleet.models import Vehicle +from shipments.models import Shipment +from assignment.models.assignment_item import AssignmentItem +import uuid + + +class AssignmentPlannerTestCase(TestCase): + def setUp(self): + self.vehicle1 = Vehicle.objects.create( + vehicle_id="TRK001", + capacity=100, + status="available", + depot_latitude=6.9, + depot_longitude=79.8 + ) + + self.vehicle2 = Vehicle.objects.create( + vehicle_id="TRK002", + capacity=80, + status="available", + depot_latitude=6.9, + depot_longitude=79.9 + ) + + self.shipment1 = Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id="ORD001", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.2, "lng": 80.6}, + demand=40, + status="pending" + ) + self.shipment2 = Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id="ORD002", + origin={"lat": 6.9, "lng": 79.9}, + destination={"lat": 7.3, "lng": 80.7}, + demand=30, + status="pending" + ) + self.shipment3 = Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id="ORD003", + origin={"lat": 7.0, "lng": 79.9}, + destination={"lat": 7.4, "lng": 80.8}, + demand=50, + status="pending" + ) + + def test_assignments_created_successfully(self): + planner = AssignmentPlanner( + vehicles=[self.vehicle1, self.vehicle2], + shipments=[self.shipment1, self.shipment2] + ) + assignments = planner.plan_assignments() + self.assertLessEqual(len(assignments), 2) + self.assertGreaterEqual(AssignmentItem.objects.count(), 2) + + def test_vehicle_handles_multiple_tasks_within_capacity(self): + planner = AssignmentPlanner( + vehicles=[self.vehicle2], # capacity 80 + shipments=[self.shipment1, self.shipment3] # demands: 40 + 50 = 90 (individually valid) + ) + assignments = planner.plan_assignments() + self.assertEqual(len(assignments), 1) + self.assertGreaterEqual(assignments[0].items.count(), 2) + + def test_assignment_fails_due_to_individual_task_exceeding_capacity(self): + high_demand_1 = Shipment.objects.create( + shipment_id="HD001", + order_id="ORDHD1", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.3, "lng": 80.6}, + demand=90, + status="pending" + ) + + high_demand_2 = Shipment.objects.create( + shipment_id="HD002", + order_id="ORDHD2", + origin={"lat": 7.0, "lng": 79.9}, + destination={"lat": 7.4, "lng": 80.7}, + demand=95, + status="pending" + ) + + # vehicle2 only has 80 capacity + planner = AssignmentPlanner( + vehicles=[self.vehicle2], + shipments=[high_demand_1, high_demand_2] + ) + with self.assertRaises(Exception) as ctx: + planner.plan_assignments() + self.assertIn("Optimization failed", str(ctx.exception)) + + def test_no_vehicles_provided(self): + planner = AssignmentPlanner(vehicles=[], shipments=[self.shipment1]) + with self.assertRaises(AssertionError): + planner.plan_assignments() + + def test_no_shipments_provided(self): + planner = AssignmentPlanner(vehicles=[self.vehicle1], shipments=[]) + assignments = planner.plan_assignments() + self.assertEqual(assignments, []) + + def test_assignments_have_delivery_items(self): + planner = AssignmentPlanner( + vehicles=[self.vehicle1, self.vehicle2], + shipments=[self.shipment1, self.shipment2] + ) + assignments = planner.plan_assignments() + for assignment in assignments: + self.assertGreater( + assignment.items.count(), 0, + f"Assignment {assignment.id} should include at least one item" + ) diff --git a/route_optimizer/models/vrp_input.py b/route_optimizer/models/vrp_input.py index 2cffe4e..ade9f8c 100644 --- a/route_optimizer/models/vrp_input.py +++ b/route_optimizer/models/vrp_input.py @@ -50,6 +50,8 @@ def validate(self): n = len(self.location_ids) assert len(self.demands) == n, "Mismatch: demands vs location_ids" assert len(self.distance_matrix) == n, "Mismatch: matrix rows vs location_ids" + assert len(self.vehicles) > 0, "No vehicles defined" + assert len(self.vehicles) == self.num_vehicles, "Mismatch: vehicles vs num_vehicles" assert all(len(row) == n for row in self.distance_matrix), "Matrix must be square" for v in self.vehicles: depot_index = self.location_id_to_index.get(f"{v.id}_depot")