Compare commits

...
Sign in to create a new pull request.

9 commits

14 changed files with 1170 additions and 1 deletions

View file

@ -70,6 +70,7 @@ INSTALLED_APPS = [
'inventory',
'mail',
'notify_sessions',
'shipments'
]
REST_FRAMEWORK = {

View file

@ -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')),
]

72
core/shipments/README.md Normal file
View file

@ -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

View file

205
core/shipments/api_v2.py Normal file
View file

@ -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<public_secret>[0-9a-f-]+)/$', public_shipment_detail, name='public-shipment-detail'),
re_path(r'^public/shipments/(?P<public_secret>[0-9a-f-]+)/update/$', public_shipment_update, name='public-shipment-update'),
]

View file

@ -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()),
],
),
]

View file

@ -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),
),
]

View file

162
core/shipments/models.py Normal file
View file

@ -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)

View file

View file

View file

@ -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)

View file

@ -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),
),
]

View file

@ -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():
@ -39,6 +39,16 @@ def setup():
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,
# permission=Permission.objects.get(codename=permission))