diff --git a/backend/inventory/models.py b/backend/inventory/models.py index 7bab2c4..56cb6d3 100644 --- a/backend/inventory/models.py +++ b/backend/inventory/models.py @@ -1,7 +1,8 @@ -from django.db import models +from django.db import models, transaction from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver +from django.utils import timezone import logging from .constants import LOCATION_CHANGING_EVENTS @@ -58,6 +59,49 @@ class Meta: def __str__(self): return f"{self.box_code} - {self.label or 'Unlabeled'}" + def mark_as_arrived(self, destination_location, user=None, comment=""): + """ + Move this box to destination and keep all contained items in sync. + """ + with transaction.atomic(): + self.location = destination_location + self.save(update_fields=["location", "updated_at"]) + + destination_is_floor = destination_location.location_type == "FLOOR" + # Fetch only the fields we actually need to avoid loading full model instances + items_data = list(self.items.values_list("id", "current_location_id")) + item_ids = [item_id for item_id, _ in items_data] + history_entries = [] + + if item_ids: + now = timezone.now() + CollectionItem.objects.filter(id__in=item_ids).update( + current_location=destination_location, + is_on_floor=destination_is_floor, + status=models.Case( + models.When(status="IN_TRANSIT", then=models.Value("AVAILABLE")), + default=models.F("status"), + output_field=models.CharField(max_length=20), + ), + updated_at=now, + ) + + for item_id, from_location_id in items_data: + history_entries.append( + ItemHistory( + item_id=item_id, + event_type="ARRIVED", + from_location_id=from_location_id, + to_location=destination_location, + acted_by=user, + notes=comment or f"Box {self.box_code} arrived at {destination_location.name}", + ) + ) + if history_entries: + ItemHistory.objects.bulk_create(history_entries) + + return len(items_data) + class CollectionItem(models.Model): """ @@ -97,10 +141,16 @@ class CollectionItem(models.Model): item_type = models.CharField(max_length=20, choices=ITEM_TYPE_CHOICES, default="SOFTWARE") condition = models.CharField( - max_length=20, choices=CONDITION_CHOICES, default="GOOD", help_text="Physical condition of the item" + max_length=20, + choices=CONDITION_CHOICES, + default="GOOD", + help_text="Physical condition of the item", ) is_complete = models.CharField( - max_length=10, choices=COMPLETENESS_CHOICES, default="UNKNOWN", help_text="Whether the item is complete with all parts" + max_length=10, + choices=COMPLETENESS_CHOICES, + default="UNKNOWN", + help_text="Whether the item is complete with all parts", ) is_functional = models.CharField( max_length=10, diff --git a/backend/inventory/tests.py b/backend/inventory/tests.py index 84660b5..f3038c9 100644 --- a/backend/inventory/tests.py +++ b/backend/inventory/tests.py @@ -1002,6 +1002,7 @@ def setUp(self): token = AccessToken.for_user(self.user) self.client = Client(HTTP_AUTHORIZATION=f"Bearer {token}") self.location = Location.objects.create(name="Storage Z", location_type="STORAGE") + self.destination_location = Location.objects.create(name="Floor A", location_type="FLOOR") self.box_a = Box.objects.create( box_code="BOX001", label="Box A", @@ -1020,6 +1021,13 @@ def setUp(self): title="Item Two", current_location=self.location, ) + self.item_c = CollectionItem.objects.create( + item_code="ITEM003", + title="Item Three", + current_location=self.location, + box=self.box_a, + status="IN_TRANSIT", + ) def test_list_boxes(self): response = self.client.get("/api/boxes/") @@ -1050,3 +1058,73 @@ def test_patch_item_box_updates_box_id(self): assert response.status_code == status.HTTP_200_OK self.item_b.refresh_from_db() assert self.item_b.box_id == self.box_b.id + + def test_mark_box_arrived_updates_box_and_items(self): + response = self.client.post( + f"/api/boxes/{self.box_a.id}/mark-arrived/", + data=json.dumps( + { + "location": self.destination_location.id, + "comment": "Received at floor", + } + ), + content_type="application/json", + ) + + assert response.status_code == status.HTTP_200_OK + data = json.loads(response.content) + assert data["moved_items"] == 2 + + self.box_a.refresh_from_db() + self.item_a.refresh_from_db() + self.item_c.refresh_from_db() + + assert self.box_a.location_id == self.destination_location.id + assert self.item_a.current_location_id == self.destination_location.id + assert self.item_c.current_location_id == self.destination_location.id + assert self.item_a.is_on_floor is True + assert self.item_c.is_on_floor is True + assert self.item_c.status == "AVAILABLE" + + assert ItemHistory.objects.filter( + item=self.item_a, + event_type="ARRIVED", + to_location=self.destination_location, + ).exists() + assert ItemHistory.objects.filter( + item=self.item_c, + event_type="ARRIVED", + to_location=self.destination_location, + ).exists() + + def test_mark_box_arrived_requires_location(self): + response = self.client.post( + f"/api/boxes/{self.box_a.id}/mark-arrived/", + data=json.dumps({}), + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_mark_box_arrived_rejects_same_location(self): + response = self.client.post( + f"/api/boxes/{self.box_a.id}/mark-arrived/", + data=json.dumps({"location": self.location.id}), + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_mark_box_arrived_rejects_invalid_location(self): + if Location.objects.exists(): + invalid_location_id = Location.objects.order_by("-id").first().id + 1 + else: + invalid_location_id = 1 + response = self.client.post( + f"/api/boxes/{self.box_a.id}/mark-arrived/", + data=json.dumps({"location": invalid_location_id}), + content_type="application/json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = json.loads(response.content) + assert data.get("detail") == "Destination location not found." diff --git a/backend/inventory/views.py b/backend/inventory/views.py index ae9af79..885f314 100644 --- a/backend/inventory/views.py +++ b/backend/inventory/views.py @@ -1,5 +1,5 @@ from rest_framework import viewsets, permissions, filters, status -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view, permission_classes, action from rest_framework.response import Response from users.permissions import IsAdmin, IsVolunteer @@ -46,16 +46,58 @@ class BoxViewSet(viewsets.ReadOnlyModelViewSet): ViewSet for boxes. - GET /api/boxes/ - List all boxes - GET /api/boxes/{id}/ - Retrieve box with items + - POST /api/boxes/{id}/mark-arrived/ - Mark box as arrived at destination """ queryset = Box.objects.all().prefetch_related("items") permission_classes = [IsVolunteer] def get_serializer_class(self): - if self.action == "retrieve": + if self.action in ["retrieve", "mark_arrived"]: return BoxDetailSerializer return BoxSerializer + @action(detail=True, methods=["post"], url_path="mark-arrived") + def mark_arrived(self, request, pk=None): + """Mark a box as arrived and sync all contained items to destination location.""" + box = self.get_object() + location_id = request.data.get("location") + + if not location_id: + return Response( + {"detail": "Location is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + destination = Location.objects.get(pk=location_id) + except (Location.DoesNotExist, ValueError, TypeError): + return Response( + {"detail": "Destination location not found."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if box.location_id == destination.id: + return Response( + {"detail": "Box is already at this location."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + moved_items = box.mark_as_arrived( + destination_location=destination, + user=request.user, + comment=request.data.get("comment", ""), + ) + box.refresh_from_db() + serializer = self.get_serializer(box) + return Response( + { + "box": serializer.data, + "moved_items": moved_items, + }, + status=status.HTTP_200_OK, + ) + class LocationViewSet(viewsets.ModelViewSet): """ diff --git a/backend/movements/tests.py b/backend/movements/tests.py index 7ce503c..84e6cee 100644 --- a/backend/movements/tests.py +++ b/backend/movements/tests.py @@ -1,3 +1,80 @@ -from django.test import TestCase +import json -# Create your tests here. +from django.test import TestCase, Client +from rest_framework import status +from rest_framework_simplejwt.tokens import AccessToken + +from users.models import User +from inventory.models import CollectionItem, Location, ItemHistory +from movements.models import ItemMovementRequest + + +class ItemMovementArrivalAPITest(TestCase): + def setUp(self): + self.volunteer = User.objects.create_user( + email="volunteer-move@example.com", + name="Volunteer Move", + password="testpass", + role="VOLUNTEER", + ) + self.admin = User.objects.create_user( + email="admin-move@example.com", + name="Admin Move", + password="testpass", + role="ADMIN", + ) + + volunteer_token = AccessToken.for_user(self.volunteer) + self.client = Client(HTTP_AUTHORIZATION=f"Bearer {volunteer_token}") + + self.from_location = Location.objects.create(name="Storage B", location_type="STORAGE") + self.to_location = Location.objects.create(name="Event Hall", location_type="EVENT") + + self.item = CollectionItem.objects.create( + item_code="MOVE001", + title="Transit Test Item", + current_location=self.from_location, + status="AVAILABLE", + is_on_floor=False, + ) + + self.move_request = ItemMovementRequest.objects.create( + item=self.item, + requested_by=self.volunteer, + from_location=self.from_location, + to_location=self.to_location, + ) + + def test_complete_arrival_updates_item_location_and_status(self): + self.move_request.approve(admin_user=self.admin, comment="Approved") + + response = self.client.post( + f"/api/movements/movement-requests/{self.move_request.id}/complete-arrival/", + data=json.dumps({"comment": "Arrived and checked"}), + content_type="application/json", + ) + + assert response.status_code == status.HTTP_200_OK + self.item.refresh_from_db() + assert self.item.current_location_id == self.to_location.id + assert self.item.status == "AVAILABLE" + + assert ItemHistory.objects.filter( + item=self.item, + event_type="ARRIVED", + to_location=self.to_location, + ).exists() + + def test_complete_arrival_rejects_when_item_not_in_transit(self): + self.move_request.status = "APPROVED" + self.move_request.admin = self.admin + self.move_request.admin_comment = "Approved" + self.move_request.save(update_fields=["status", "admin", "admin_comment", "updated_at"]) + + response = self.client.post( + f"/api/movements/movement-requests/{self.move_request.id}/complete-arrival/", + data=json.dumps({}), + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST