diff --git a/core/core/urls.py b/core/core/urls.py index 2386891..55093e9 100644 --- a/core/core/urls.py +++ b/core/core/urls.py @@ -30,6 +30,7 @@ urlpatterns = [ path('api/2/', include('mail.api_v2')), path('api/2/', include('notify_sessions.api_v2')), path('api/2/', include('authentication.api_v2')), + path('api/2/', include('shipments.api_v2')), path('api/', get_info), path('', include('django_prometheus.urls')), ] diff --git a/core/shipments/api_v2.py b/core/shipments/api_v2.py index e69de29..c68adf7 100644 --- a/core/shipments/api_v2.py +++ b/core/shipments/api_v2.py @@ -0,0 +1,108 @@ +from django.urls import re_path +from rest_framework import routers, viewsets, status +from rest_framework.decorators import api_view, permission_classes, action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework import serializers + +from .models import Shipment +from inventory.models import Item +from tickets.models import IssueThread + + +class ShipmentSerializer(serializers.ModelSerializer): + related_items = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Item.objects.all(), + required=False + ) + related_tickets = serializers.PrimaryKeyRelatedField( + many=True, + queryset=IssueThread.objects.all(), + required=False + ) + + class Meta: + model = Shipment + fields = [ + 'id', 'public_secret', 'state', 'review_requirement', + 'recipient_name', 'address_supplements', 'street_address', + 'city', 'state_province', 'postal_code', 'country', + 'related_items', 'related_tickets', + 'created_at', 'updated_at', 'shipped_at', 'completed_at' + ] + read_only_fields = ['id', 'public_secret', 'created_at', 'updated_at', 'shipped_at', 'completed_at'] + + +class ShipmentViewSet(viewsets.ModelViewSet): + serializer_class = ShipmentSerializer + queryset = Shipment.objects.all() + permission_classes = [IsAuthenticated] + + @action(detail=True, methods=['post']) + def add_items(self, request, pk=None): + shipment = self.get_object() + review_requirement = request.data.get('review_requirement') + item_ids = request.data.get('item_ids', []) + + try: + items = Item.objects.filter(id__in=item_ids) + shipment.related_items.add(*items) + shipment.add_items(review_requirement) + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def approve(self, request, pk=None): + shipment = self.get_object() + try: + shipment.approve() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def reject(self, request, pk=None): + shipment = self.get_object() + try: + shipment.reject() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def mark_shipped(self, request, pk=None): + shipment = self.get_object() + try: + # Validate state transition before attempting to mark as shipped + if shipment.state != Shipment.READY_FOR_SHIPPING: + return Response( + {'error': f'Invalid state transition: Cannot mark shipment as shipped from {shipment.state} state. Shipment must be in READY_FOR_SHIPPING state.'}, + status=status.HTTP_400_BAD_REQUEST + ) + shipment.mark_shipped() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def mark_completed(self, request, pk=None): + shipment = self.get_object() + try: + # Validate state transition before attempting to mark as completed + if shipment.state != Shipment.SHIPPED: + return Response( + {'error': f'Invalid state transition: Cannot mark shipment as completed from {shipment.state} state. Shipment must be in SHIPPED state.'}, + status=status.HTTP_400_BAD_REQUEST + ) + shipment.mark_completed() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +router = routers.SimpleRouter() +router.register(r'shipments', ShipmentViewSet, basename='shipments') + +urlpatterns = router.urls diff --git a/core/shipments/tests/__init__.py b/core/shipments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/tests/v2/__init__.py b/core/shipments/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/tests/v2/test_api.py b/core/shipments/tests/v2/test_api.py new file mode 100644 index 0000000..2de38a9 --- /dev/null +++ b/core/shipments/tests/v2/test_api.py @@ -0,0 +1,324 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken +from django.utils import timezone + +from authentication.models import ExtendedUser +from inventory.models import Event, Item, Container +from tickets.models import IssueThread +from shipments.models import Shipment + + +class ShipmentApiTest(TestCase): + def setUp(self): + super().setUp() + # Create a test user with all permissions + self.user = ExtendedUser.objects.create_user('testuser', 'test@example.com', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + + # Create authentication token + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + # Create test event and container + self.event = Event.objects.create(slug='test-event', name='Test Event') + self.container = Container.objects.create(name='Test Container') + + # Create test items with container + self.item1 = Item.objects.create(description='Test Item 1', event=self.event, container=self.container) + self.item2 = Item.objects.create(description='Test Item 2', event=self.event, container=self.container) + + # Create test ticket + self.ticket = IssueThread.objects.create(name='Test Ticket') + + def test_list_shipments_empty(self): + """Test listing shipments when none exist""" + response = self.client.get('/api/2/shipments/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_create_shipment(self): + """Test creating a new shipment""" + data = { + 'recipient_name': 'John Smith', + 'street_address': '123 Oxford Street', + 'city': 'London', + 'state_province': 'Greater London', + 'postal_code': 'W1D 2HG', + 'country': 'United Kingdom', + 'related_items': [], + 'related_tickets': [] + } + response = self.client.post('/api/2/shipments/', data, content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['recipient_name'], 'John Smith') + self.assertEqual(response.json()['state'], Shipment.CREATED) + self.assertEqual(response.json()['review_requirement'], None) + + # Retrieve the created shipment and verify its details + shipment_id = response.json()['id'] + get_response = self.client.get(f'/api/2/shipments/{shipment_id}/') + self.assertEqual(get_response.status_code, 200) + self.assertEqual(get_response.json()['id'], shipment_id) + self.assertEqual(get_response.json()['recipient_name'], 'John Smith') + self.assertEqual(get_response.json()['street_address'], '123 Oxford Street') + self.assertEqual(get_response.json()['city'], 'London') + self.assertEqual(get_response.json()['state_province'], 'Greater London') + self.assertEqual(get_response.json()['postal_code'], 'W1D 2HG') + self.assertEqual(get_response.json()['country'], 'United Kingdom') + self.assertEqual(get_response.json()['state'], Shipment.CREATED) + self.assertEqual(get_response.json()['review_requirement'], None) + + def test_get_shipment(self): + """Test retrieving a specific shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Maria Garcia', + street_address='Calle Gran Via 28', + city='Madrid', + state_province='Madrid', + postal_code='28013', + country='Spain' + ) + + response = self.client.get(f'/api/2/shipments/{shipment.id}/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], shipment.id) + self.assertEqual(response.json()['recipient_name'], 'Maria Garcia') + + def test_update_shipment(self): + """Test updating a shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Hans Mueller', + street_address='Kurfürstendamm 216', + city='Berlin', + state_province='Berlin', + postal_code='10719', + country='Germany' + ) + + data = { + 'recipient_name': 'Hans-Jürgen Mueller', + 'address_supplements': 'Apartment 4B' + } + response = self.client.patch(f'/api/2/shipments/{shipment.id}/', data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['recipient_name'], 'Hans-Jürgen Mueller') + self.assertEqual(response.json()['address_supplements'], 'Apartment 4B') + + def test_delete_shipment(self): + """Test deleting a shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Sophie Dubois', + street_address='15 Rue de Rivoli', + city='Paris', + state_province='Île-de-France', + postal_code='75001', + country='France' + ) + + response = self.client.delete(f'/api/2/shipments/{shipment.id}/') + self.assertEqual(response.status_code, 204) + + # Verify it's soft deleted - use all_objects manager to see deleted items + self.assertTrue(Shipment.all_objects.filter(id=shipment.id).exists()) + # Check if it's marked as deleted + shipment.refresh_from_db() + self.assertIsNotNone(shipment.deleted_at) + + def test_add_items(self): + """Test adding items to a shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Alessandro Romano', + street_address='Via del Corso 123', + city='Rome', + state_province='Lazio', + postal_code='00186', + country='Italy' + ) + + data = { + 'item_ids': [self.item1.id, self.item2.id], + 'review_requirement': Shipment.REVIEW_ITEMS + } + response = self.client.post(f'/api/2/shipments/{shipment.id}/add_items/', data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.PENDING_REVIEW) + self.assertEqual(response.json()['review_requirement'], Shipment.REVIEW_ITEMS) + + # Verify items were added + shipment.refresh_from_db() + self.assertEqual(shipment.related_items.count(), 2) + + def test_approve_shipment(self): + """Test approving a shipment""" + # Create a shipment and add items + shipment = Shipment.objects.create( + recipient_name='Yuki Tanaka', + street_address='1-1-1 Shibuya', + city='Tokyo', + state_province='Tokyo', + postal_code='150-0002', + country='Japan' + ) + shipment.related_items.add(self.item1) + shipment.review_requirement = Shipment.REVIEW_ITEMS + shipment.state = Shipment.PENDING_REVIEW + shipment.save() + + response = self.client.post(f'/api/2/shipments/{shipment.id}/approve/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.READY_FOR_SHIPPING) + + def test_reject_shipment(self): + """Test rejecting a shipment""" + # Create a shipment and add items + shipment = Shipment.objects.create( + recipient_name='Carlos Rodriguez', + street_address='Avenida Insurgentes Sur 1602', + city='Mexico City', + state_province='CDMX', + postal_code='03940', + country='Mexico' + ) + shipment.related_items.add(self.item1) + shipment.review_requirement = Shipment.REVIEW_ITEMS + shipment.state = Shipment.PENDING_REVIEW + shipment.save() + + response = self.client.post(f'/api/2/shipments/{shipment.id}/reject/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.REJECTED) + + def test_mark_shipped(self): + """Test marking a shipment as shipped""" + # Create a shipment and set it to READY_FOR_SHIPPING + shipment = Shipment.objects.create( + recipient_name='Anna Kowalski', + street_address='ul. Marszałkowska 126', + city='Warsaw', + state_province='Mazowieckie', + postal_code='00-008', + country='Poland', + state=Shipment.READY_FOR_SHIPPING + ) + shipment.related_items.add(self.item1) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/mark_shipped/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.SHIPPED) + self.assertIsNotNone(response.json()['shipped_at']) + + def test_mark_completed(self): + """Test marking a shipment as completed""" + # Create a shipment and set it to SHIPPED + shipment = Shipment.objects.create( + recipient_name='Lars Andersen', + street_address='Strøget 1', + city='Copenhagen', + state_province='Capital Region', + postal_code='1095', + country='Denmark', + state=Shipment.SHIPPED, + shipped_at=timezone.now() + ) + shipment.related_items.add(self.item1) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/mark_completed/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.COMPLETED) + self.assertIsNotNone(response.json()['completed_at']) + + def test_invalid_state_transition(self): + """Test that invalid state transitions are rejected""" + # Create a shipment in CREATED state + shipment = Shipment.objects.create( + recipient_name='Ahmed Hassan', + street_address='26 July Street', + city='Cairo', + state_province='Cairo Governorate', + postal_code='11511', + country='Egypt' + ) + + # Try to mark it as shipped directly (invalid transition) + response = self.client.post(f'/api/2/shipments/{shipment.id}/mark_shipped/') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + self.assertIn('Invalid state transition', response.json()['error']) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.CREATED) + + def test_add_items_without_review_requirement(self): + """Test adding items without specifying review requirement""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Wei Chen', + street_address='88 Century Avenue', + city='Shanghai', + state_province='Shanghai', + postal_code='200120', + country='China' + ) + + data = { + 'item_ids': [self.item1.id, self.item2.id] + } + response = self.client.post(f'/api/2/shipments/{shipment.id}/add_items/', data, content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.CREATED) + + def test_approve_without_items(self): + """Test approving a shipment without items""" + # Create a shipment with REVIEW_ITEMS requirement + shipment = Shipment.objects.create( + recipient_name='Raj Patel', + street_address='123 MG Road', + city='Mumbai', + state_province='Maharashtra', + postal_code='400001', + country='India', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ITEMS + ) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/approve/') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW) + + def test_approve_without_address(self): + """Test approving a shipment without complete address""" + # Create a shipment with REVIEW_ADDRESS requirement + shipment = Shipment.objects.create( + recipient_name='Sofia Oliveira', + street_address='Avenida Paulista 1000', + city='São Paulo', + state_province='São Paulo', + postal_code='01310-100', + country='', # Missing country + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ADDRESS + ) + shipment.related_items.add(self.item1) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/approve/') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW)