Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions backend/inventory/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
items = list(self.items.select_related("current_location"))
item_ids = [item.id for item in items]
history_entries = []

if item_ids:
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=timezone.now(),
)

for item in items:
from_location = item.current_location

history_entries.append(
ItemHistory(
item=item,
event_type="ARRIVED",
from_location=from_location,
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)


class CollectionItem(models.Model):
"""
Expand Down Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions backend/inventory/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/")
Expand Down Expand Up @@ -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."
46 changes: 44 additions & 2 deletions backend/inventory/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
"""
Expand Down
81 changes: 79 additions & 2 deletions backend/movements/tests.py
Original file line number Diff line number Diff line change
@@ -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
Loading