diff --git a/core/core/settings.py b/core/core/settings.py index 805a27b..90e026b 100644 --- a/core/core/settings.py +++ b/core/core/settings.py @@ -70,6 +70,7 @@ INSTALLED_APPS = [ 'inventory', 'mail', 'notify_sessions', + 'shipments' ] REST_FRAMEWORK = { 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/README.md b/core/shipments/README.md new file mode 100644 index 0000000..4666acc --- /dev/null +++ b/core/shipments/README.md @@ -0,0 +1,72 @@ +# Shipment Management +## Functional Description +This module handles shipments that are processed by the lost&found team. + +**Feature List** +- Shipment can contain *n* (often one, but a lostee can also be sent multiple items) items +- Shipment is linked to *n* tickets (for informative purposes) +- Shipment holds the address of the parcel +- Shipment holds the dhl voucher used +- On creation of a shipment, the agent can activate the features "adress retrieval", "item validation" and/or tracking + - **address retrieval**: the lostee recieves a email with publicly accessible link, on this page the user can enter his address. + - address sanitation + - address validation + - automatic state change after successful address entry (-> waiting for shipping) + - **item validation**: the lostee recieves a email with publicly accessible link, on this page the user see the images and confirm the items + - automatic state change after confirmation + - **tracking**: the lostee recieves a email with publicly accessible link, on this page the user can enter his address +- Returning parcels could be managed to (e.g. if a shipment is marked as returned, the items will be *unfound* again) + +## State Model +The shipment process follows a defined state model to ensure proper workflow and validation: + +### States +1. **CREATED** (Initial State) + - Shipment is created without address, items, or tickets + - No validation requirements + +2. **PENDING_REVIEW** + - Items have been added to the shipment + - Waiting for requester to verify items and/or address + - Requires specification of what needs to be reviewed: + - Items only + - Address only + - Both items and address + - Validation requirements depend on review type + +3. **READY_FOR_SHIPPING** + - All required reviews completed + - All required fields validated + - Ready for shipping process + +4. **REJECTED** + - Shipment was reviewed and rejected + - Terminal state (no further transitions possible) + +5. **SHIPPED** + - Package has been shipped + - Requires complete address information + - Timestamp of shipping is recorded + +6. **COMPLETED** + - Package has been delivered + - Terminal state (no further transitions possible) + - Timestamp of completion is recorded + +### State Transitions +- CREATED → PENDING_REVIEW (when items are added, requires specification of review type) +- PENDING_REVIEW → READY_FOR_SHIPPING (when approved and all required reviews completed) +- PENDING_REVIEW → REJECTED (when rejected) +- READY_FOR_SHIPPING → SHIPPED (when marked as shipped) +- SHIPPED → COMPLETED (when marked as delivered) + +### Validation Rules +- Review requirement must be specified when entering PENDING_REVIEW state +- Address fields are required for shipping-related states (READY_FOR_SHIPPING, SHIPPED, COMPLETED) +- At least one item is required for any state beyond CREATED +- Invalid state transitions will raise validation errors +- Terminal states (REJECTED, COMPLETED) cannot transition to other states +- Approval requires completion of all specified review requirements: + - For ITEMS review: items must be added + - For ADDRESS review: all address fields must be complete + - For BOTH review: both items and complete address are required \ No newline at end of file diff --git a/core/shipments/__init__.py b/core/shipments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/api_v2.py b/core/shipments/api_v2.py new file mode 100644 index 0000000..0c68d0c --- /dev/null +++ b/core/shipments/api_v2.py @@ -0,0 +1,205 @@ +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, AllowAny +from rest_framework import serializers +from django.shortcuts import get_object_or_404 + +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 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() + 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) + + +# 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 + [ + 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/migrations/0001_initial.py b/core/shipments/migrations/0001_initial.py new file mode 100644 index 0000000..eb889e2 --- /dev/null +++ b/core/shipments/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.7 on 2025-03-15 21:37 + +from django.db import migrations, models +import django.db.models.manager +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('inventory', '0007_rename_container_item_container_old_itemplacement_and_more'), + ('tickets', '0013_alter_statechange_state'), + ] + + operations = [ + migrations.CreateModel( + name='Shipment', + fields=[ + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('public_secret', models.UUIDField(default=uuid.uuid4)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ('related_items', models.ManyToManyField(to='inventory.item')), + ('related_tickets', models.ManyToManyField(to='tickets.issuethread')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('all_objects', django.db.models.manager.Manager()), + ], + ), + ] diff --git a/core/shipments/migrations/0002_shipment_address_supplements_shipment_city_and_more.py b/core/shipments/migrations/0002_shipment_address_supplements_shipment_city_and_more.py new file mode 100644 index 0000000..f5df569 --- /dev/null +++ b/core/shipments/migrations/0002_shipment_address_supplements_shipment_city_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 4.2.7 on 2025-04-11 15:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipments', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='shipment', + name='address_supplements', + field=models.TextField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='shipment', + name='city', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='shipment', + name='completed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='shipment', + name='country', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='shipment', + name='postal_code', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='shipment', + name='recipient_name', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='shipment', + name='review_requirement', + field=models.CharField(blank=True, choices=[('ITEMS', 'Items Review Required'), ('ADDRESS', 'Address Review Required'), ('BOTH', 'Both Items and Address Review Required')], max_length=10, null=True), + ), + migrations.AddField( + model_name='shipment', + name='shipped_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='shipment', + name='state', + field=models.CharField(choices=[('CREATED', 'Created'), ('PENDING_REVIEW', 'Pending Review'), ('READY_FOR_SHIPPING', 'Ready for Shipping'), ('REJECTED', 'Rejected'), ('SHIPPED', 'Shipped'), ('COMPLETED', 'Completed')], default='CREATED', max_length=20), + ), + migrations.AddField( + model_name='shipment', + name='state_province', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='shipment', + name='street_address', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/core/shipments/migrations/__init__.py b/core/shipments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/models.py b/core/shipments/models.py new file mode 100644 index 0000000..b98f88d --- /dev/null +++ b/core/shipments/models.py @@ -0,0 +1,162 @@ +import uuid +from django.db import models +from django.core.exceptions import ValidationError +from django.utils import timezone +from django_softdelete.models import SoftDeleteModel, SoftDeleteManager +from inventory.models import Item +from tickets.models import IssueThread + +class Shipment(SoftDeleteModel): + # State choices + CREATED = 'CREATED' + PENDING_REVIEW = 'PENDING_REVIEW' + READY_FOR_SHIPPING = 'READY_FOR_SHIPPING' + REJECTED = 'REJECTED' + SHIPPED = 'SHIPPED' + COMPLETED = 'COMPLETED' + + SHIPMENT_STATES = [ + (CREATED, 'Created'), + (PENDING_REVIEW, 'Pending Review'), + (READY_FOR_SHIPPING, 'Ready for Shipping'), + (REJECTED, 'Rejected'), + (SHIPPED, 'Shipped'), + (COMPLETED, 'Completed'), + ] + + # Review requirement choices + REVIEW_ITEMS = 'ITEMS' + REVIEW_ADDRESS = 'ADDRESS' + REVIEW_BOTH = 'BOTH' + + REVIEW_REQUIREMENTS = [ + (REVIEW_ITEMS, 'Items Review Required'), + (REVIEW_ADDRESS, 'Address Review Required'), + (REVIEW_BOTH, 'Both Items and Address Review Required'), + ] + + id = models.AutoField(primary_key=True) + public_secret = models.UUIDField(default=uuid.uuid4) + state = models.CharField( + max_length=20, + choices=SHIPMENT_STATES, + default=CREATED + ) + review_requirement = models.CharField( + max_length=10, + choices=REVIEW_REQUIREMENTS, + null=True, + blank=True + ) + + # Shipping address fields + recipient_name = models.CharField(max_length=255, blank=True, default='') + address_supplements = models.TextField(max_length=255, blank=True, default='') + street_address = models.CharField(max_length=255, blank=True, default='') + city = models.CharField(max_length=100, blank=True, default='') + state_province = models.CharField(max_length=100, blank=True, default='') + postal_code = models.CharField(max_length=20, blank=True, default='') + country = models.CharField(max_length=100, blank=True, default='') + + related_items = models.ManyToManyField(Item) + related_tickets = models.ManyToManyField(IssueThread) + + created_at = models.DateTimeField(null=True, auto_now_add=True) + updated_at = models.DateTimeField(blank=True, null=True) + shipped_at = models.DateTimeField(blank=True, null=True) + completed_at = models.DateTimeField(blank=True, null=True) + + all_objects = models.Manager() + + def __str__(self): + return f'[{self.id}] {self.state}' + + def clean(self): + """Validate state transitions and required fields""" + if not self.pk: # New instance + return + + old_instance = Shipment.objects.get(pk=self.pk) + + # Validate state transitions + valid_transitions = { + self.CREATED: [self.PENDING_REVIEW], + self.PENDING_REVIEW: [self.READY_FOR_SHIPPING, self.REJECTED], + self.READY_FOR_SHIPPING: [self.SHIPPED], + self.SHIPPED: [self.COMPLETED], + self.REJECTED: [], # No transitions from rejected + self.COMPLETED: [], # No transitions from completed + } + + if (self.state != old_instance.state and + self.state not in valid_transitions[old_instance.state]): + raise ValidationError(f'Invalid state transition from {old_instance.state} to {self.state}') + + # Validate review requirement when entering PENDING_REVIEW + if self.state == self.PENDING_REVIEW and old_instance.state == self.CREATED: + if not self.review_requirement: + raise ValidationError('Review requirement must be specified when entering PENDING_REVIEW state') + + # Validate required fields based on state and review requirement + if self.state in [self.READY_FOR_SHIPPING, self.SHIPPED, self.COMPLETED]: + if not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('All address fields are required for shipping') + + if self.state != self.CREATED and not self.related_items.exists(): + raise ValidationError('Shipment must have at least one item') + + def add_items(self, review_requirement): + """Move shipment to PENDING_REVIEW after items are added""" + if self.state == self.CREATED and self.related_items.exists(): + if not review_requirement: + raise ValidationError('Review requirement must be specified when adding items') + if review_requirement not in [self.REVIEW_ITEMS, self.REVIEW_ADDRESS, self.REVIEW_BOTH]: + raise ValidationError(f'Invalid review requirement: {review_requirement}') + + self.review_requirement = review_requirement + self.state = self.PENDING_REVIEW + self.save() + + def approve(self): + """Approve shipment for shipping""" + if self.state == self.PENDING_REVIEW: + # Validate that all required reviews are completed + if self.review_requirement == self.REVIEW_ITEMS and not self.related_items.exists(): + raise ValidationError('Items must be added before approval') + elif self.review_requirement == self.REVIEW_ADDRESS and not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('Address must be complete before approval') + elif self.review_requirement == self.REVIEW_BOTH: + if not self.related_items.exists(): + raise ValidationError('Items must be added before approval') + if not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('Address must be complete before approval') + + self.state = self.READY_FOR_SHIPPING + self.save() + + def reject(self): + """Reject shipment""" + if self.state == self.PENDING_REVIEW: + self.state = self.REJECTED + self.save() + + def mark_shipped(self): + """Mark shipment as shipped""" + if self.state == self.READY_FOR_SHIPPING: + self.state = self.SHIPPED + self.shipped_at = timezone.now() + self.save() + + def mark_completed(self): + """Mark shipment as completed""" + if self.state == self.SHIPPED: + self.state = self.COMPLETED + self.completed_at = timezone.now() + self.save() + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) \ No newline at end of file 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..a6ad468 --- /dev/null +++ b/core/shipments/tests/v2/test_api.py @@ -0,0 +1,595 @@ +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) + + 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) diff --git a/core/tickets/migrations/0013_alter_statechange_state.py b/core/tickets/migrations/0013_alter_statechange_state.py new file mode 100644 index 0000000..6a99ce5 --- /dev/null +++ b/core/tickets/migrations/0013_alter_statechange_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2025-03-15 21:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0012_remove_issuethread_related_items_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='statechange', + name='state', + field=models.CharField(choices=[('pending_new', 'New'), ('pending_open', 'Open'), ('pending_shipping', 'Needs to be shipped'), ('pending_physical_confirmation', 'Needs to be confirmed physically'), ('pending_return', 'Needs to be returned'), ('pending_postponed', 'Postponed'), ('pending_suspected_spam', 'Suspected Spam'), ('waiting_details', 'Waiting for details'), ('waiting_pre_shipping', 'Waiting for Address/Shipping Info'), ('closed_returned', 'Closed: Returned'), ('closed_shipped', 'Closed: Shipped'), ('closed_not_found', 'Closed: Not found'), ('closed_not_our_problem', 'Closed: Not our problem'), ('closed_duplicate', 'Closed: Duplicate'), ('closed_timeout', 'Closed: Timeout'), ('closed_spam', 'Closed: Spam'), ('closed_nothing_missing', 'Closed: Nothing missing'), ('closed_wtf', 'Closed: WTF'), ('found_open', 'Item Found and stored externally'), ('found_closed', 'Item Found and stored externally and closed')], default='pending_new', max_length=255), + ), + ] diff --git a/deploy/testdata.py b/deploy/testdata.py index dca385f..6cb3b33 100644 --- a/deploy/testdata.py +++ b/deploy/testdata.py @@ -3,7 +3,7 @@ import os def setup(): from authentication.models import ExtendedUser, EventPermission - from inventory.models import Event + from inventory.models import Event, Container, Item from django.contrib.auth.models import Permission, Group permissions = ['add_item', 'view_item', 'view_file', 'delete_item', 'change_item'] if not ExtendedUser.objects.filter(username='admin').exists(): @@ -38,6 +38,16 @@ def setup(): start='2024-12-18 00:00:00.000000', end='2024-12-27 00:00:00.000000', pre_start='2024-12-31 00:00:00.000000', post_end='2025-01-04 00:00:00.000000')[ 0] + + container1 ,_= Container.objects.get_or_create(id=1, name='Box1') + container2 ,_= Container.objects.get_or_create(id=2, name='Box2') + + testitem1 ,_= Item.objects.get_or_create(id=1, event=event1, description="Test item 1",uid_deprecated=111) + testitem1.container = container1 + testitem2 ,_= Item.objects.get_or_create(id=2, event=event2, description="Test item 2",uid_deprecated=112) + testitem2.container = container1 + testitem3 ,_= Item.objects.get_or_create(id=3, event=event2, description="Test item 3",uid_deprecated=113) + testitem3.container = container2 # for permission in permissions: # EventPermission.objects.create(event=event_37c3, user=foo,