From 3d01f167503611858202a34fcdb8a439a4d38eff Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Fri, 11 Apr 2025 22:13:05 +0200 Subject: [PATCH] added public api for shipments --- core/shipments/api_v2.py | 101 ++++++++++- core/shipments/tests/v2/test_api.py | 271 ++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 2 deletions(-) diff --git a/core/shipments/api_v2.py b/core/shipments/api_v2.py index c68adf7..0c68d0c 100644 --- a/core/shipments/api_v2.py +++ b/core/shipments/api_v2.py @@ -2,8 +2,9 @@ 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.permissions import IsAuthenticated, AllowAny from rest_framework import serializers +from django.shortcuts import get_object_or_404 from .models import Shipment from inventory.models import Item @@ -34,6 +35,32 @@ class ShipmentSerializer(serializers.ModelSerializer): read_only_fields = ['id', 'public_secret', 'created_at', 'updated_at', 'shipped_at', 'completed_at'] +class PublicShipmentSerializer(serializers.ModelSerializer): + related_items = serializers.PrimaryKeyRelatedField( + many=True, + read_only=True + ) + + 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', 'created_at' + ] + read_only_fields = ['id', 'public_secret', 'state', 'review_requirement', 'created_at'] + + +class ShipmentAddressUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Shipment + fields = [ + 'recipient_name', 'address_supplements', 'street_address', + 'city', 'state_province', 'postal_code', 'country' + ] + + class ShipmentViewSet(viewsets.ModelViewSet): serializer_class = ShipmentSerializer queryset = Shipment.objects.all() @@ -102,7 +129,77 @@ class ShipmentViewSet(viewsets.ModelViewSet): return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) +# Public API endpoints for shipment recipients +@api_view(['GET']) +@permission_classes([AllowAny]) +def public_shipment_detail(request, public_secret): + """ + Retrieve shipment details using public_secret. + Only accessible when shipment is in PENDING_REVIEW state. + """ + try: + shipment = Shipment.objects.get(public_secret=public_secret) + + # Only allow access to shipments in PENDING_REVIEW state + if shipment.state != Shipment.PENDING_REVIEW: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = PublicShipmentSerializer(shipment) + return Response(serializer.data) + except Shipment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +@api_view(['POST']) +@permission_classes([AllowAny]) +def public_shipment_update(request, public_secret): + """ + Update shipment with address information and transition state. + Only accessible when shipment is in PENDING_REVIEW state. + """ + try: + shipment = Shipment.objects.get(public_secret=public_secret) + + # Only allow updates to shipments in PENDING_REVIEW state + if shipment.state != Shipment.PENDING_REVIEW: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = ShipmentAddressUpdateSerializer(shipment, data=request.data, partial=True) + if serializer.is_valid(): + # Update address fields + for field, value in serializer.validated_data.items(): + setattr(shipment, field, value) + + # Update the shipment state based on review requirement + try: + if shipment.review_requirement == Shipment.REVIEW_ADDRESS: + # If only address review was required, we can approve directly + shipment.approve() + elif shipment.review_requirement == Shipment.REVIEW_BOTH: + # If both items and address review were required, we need to check if items exist + if not shipment.related_items.exists(): + return Response( + {'error': 'Items must be added before approval'}, + status=status.HTTP_400_BAD_REQUEST + ) + shipment.approve() + else: + # For ITEMS review requirement, we just save the address but don't change state + shipment.save() + + return Response(PublicShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Shipment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + router = routers.SimpleRouter() router.register(r'shipments', ShipmentViewSet, basename='shipments') -urlpatterns = router.urls +urlpatterns = router.urls + [ + re_path(r'^public/shipments/(?P[0-9a-f-]+)/$', public_shipment_detail, name='public-shipment-detail'), + re_path(r'^public/shipments/(?P[0-9a-f-]+)/update/$', public_shipment_update, name='public-shipment-update'), +] diff --git a/core/shipments/tests/v2/test_api.py b/core/shipments/tests/v2/test_api.py index 2de38a9..a6ad468 100644 --- a/core/shipments/tests/v2/test_api.py +++ b/core/shipments/tests/v2/test_api.py @@ -322,3 +322,274 @@ class ShipmentApiTest(TestCase): # Verify state hasn't changed shipment.refresh_from_db() self.assertEqual(shipment.state, Shipment.PENDING_REVIEW) + + def test_public_shipment_detail_success(self): + """Test retrieving shipment details via public API with valid public_secret""" + # Create a shipment in PENDING_REVIEW state + shipment = Shipment.objects.create( + recipient_name='John Doe', + street_address='123 Main St', + city='Anytown', + state_province='State', + postal_code='12345', + country='Country', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ADDRESS + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test retrieving shipment details + response = public_client.get(f'/api/2/public/shipments/{shipment.public_secret}/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], shipment.id) + self.assertEqual(response.json()['recipient_name'], 'John Doe') + self.assertEqual(response.json()['state'], Shipment.PENDING_REVIEW) + self.assertEqual(len(response.json()['related_items']), 1) + + def test_public_shipment_detail_not_found(self): + """Test retrieving shipment details with non-existent public_secret""" + # Create a client without authentication + public_client = Client() + + # Test with non-existent public_secret + response = public_client.get('/api/2/public/shipments/00000000-0000-0000-0000-000000000000/') + self.assertEqual(response.status_code, 404) + + def test_public_shipment_detail_wrong_state(self): + """Test retrieving shipment details when shipment is not in PENDING_REVIEW state""" + # Create a shipment in CREATED state + shipment = Shipment.objects.create( + recipient_name='Jane Smith', + street_address='456 Oak Ave', + city='Somewhere', + state_province='Province', + postal_code='67890', + country='Nation', + state=Shipment.CREATED + ) + + # Create a client without authentication + public_client = Client() + + # Test retrieving shipment details + response = public_client.get(f'/api/2/public/shipments/{shipment.public_secret}/') + self.assertEqual(response.status_code, 404) + + def test_public_shipment_update_success_address_only(self): + """Test updating shipment with address information when review_requirement is ADDRESS""" + # Create a shipment in PENDING_REVIEW state with ADDRESS review requirement + shipment = Shipment.objects.create( + recipient_name='Alice Johnson', + street_address='789 Pine Rd', + city='Nowhere', + state_province='Region', + postal_code='13579', + country='Land', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ADDRESS + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Alice Johnson', + 'street_address': '789 Pine Rd, Apt 4B', + 'city': 'Nowhere', + 'state_province': 'Region', + 'postal_code': '13579', + 'country': 'Land' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.READY_FOR_SHIPPING) + self.assertEqual(response.json()['street_address'], '789 Pine Rd, Apt 4B') + + # Verify state has changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.READY_FOR_SHIPPING) + + def test_public_shipment_update_success_both_review(self): + """Test updating shipment with address information when review_requirement is BOTH""" + # Create a shipment in PENDING_REVIEW state with BOTH review requirement + shipment = Shipment.objects.create( + recipient_name='Bob Wilson', + street_address='321 Elm St', + city='Elsewhere', + state_province='Territory', + postal_code='24680', + country='Realm', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_BOTH + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Bob Wilson', + 'street_address': '321 Elm St, Suite 7', + 'city': 'Elsewhere', + 'state_province': 'Territory', + 'postal_code': '24680', + 'country': 'Realm' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.READY_FOR_SHIPPING) + self.assertEqual(response.json()['street_address'], '321 Elm St, Suite 7') + + # Verify state has changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.READY_FOR_SHIPPING) + + def test_public_shipment_update_success_items_only(self): + """Test updating shipment with address information when review_requirement is ITEMS""" + # Create a shipment in PENDING_REVIEW state with ITEMS review requirement + shipment = Shipment.objects.create( + recipient_name='Carol Davis', + street_address='654 Maple Dr', + city='Anywhere', + state_province='District', + postal_code='97531', + country='Kingdom', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ITEMS + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Carol Davis', + 'street_address': '654 Maple Dr, Unit 3', + 'city': 'Anywhere', + 'state_province': 'District', + 'postal_code': '97531', + 'country': 'Kingdom' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.PENDING_REVIEW) # State should not change + self.assertEqual(response.json()['street_address'], '654 Maple Dr, Unit 3') + + # Verify state has not changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW) + + def test_public_shipment_update_not_found(self): + """Test updating shipment with non-existent public_secret""" + # Create a client without authentication + public_client = Client() + + # Test with non-existent public_secret + data = { + 'recipient_name': 'Unknown Person', + 'street_address': '123 Fake St', + 'city': 'Fake City', + 'state_province': 'Fake State', + 'postal_code': '12345', + 'country': 'Fake Country' + } + response = public_client.post( + '/api/2/public/shipments/00000000-0000-0000-0000-000000000000/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 404) + + def test_public_shipment_update_wrong_state(self): + """Test updating shipment when it's not in PENDING_REVIEW state""" + # Create a shipment in CREATED state + shipment = Shipment.objects.create( + recipient_name='David Lee', + street_address='987 Cedar Ln', + city='Nowhere', + state_province='Province', + postal_code='13579', + country='Country', + state=Shipment.CREATED + ) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment + data = { + 'recipient_name': 'David Lee', + 'street_address': '987 Cedar Ln, Apt 2C', + 'city': 'Nowhere', + 'state_province': 'Province', + 'postal_code': '13579', + 'country': 'Country' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 404) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.CREATED) + + def test_public_shipment_update_both_review_missing_items(self): + """Test updating shipment with BOTH review requirement when items are missing""" + # Create a shipment in PENDING_REVIEW state with BOTH review requirement but no items + shipment = Shipment.objects.create( + recipient_name='Eve Brown', + street_address='147 Birch Ave', + city='Somewhere', + state_province='Region', + postal_code='24680', + country='Land', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_BOTH + ) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Eve Brown', + 'street_address': '147 Birch Ave, Suite 5', + 'city': 'Somewhere', + 'state_province': 'Region', + 'postal_code': '24680', + 'country': 'Land' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + self.assertIn('Items must be added before approval', response.json()['error']) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW)