Compare commits

..

9 commits

16 changed files with 1152 additions and 227 deletions

158
README.md
View file

@ -1,158 +0,0 @@
# C3LF System3
the third try to automate lost&found organization for chaos events. not a complete rewrite, but instead building on top
of the web frontend of version 2. everything else is new but still API compatible. now with more monorepo.
## Architecture
C3LF System3 integrates a Django-Rest-Framework + WebSocket backend, Vue.js frontend SPA and a minimal LMTP mail server
integrated with the Django backend. It is additionally deployed with a Postfix mail server as Proxy in front of the
LMTP socket, a MariaDB database, a Redis cache and an Nginx reverse proxy that serves the static SPA frontend, proxies
the API requests to the backend and serves the media files in cooperation with the Django backend using the
`X-Accel-Redirect` header.
The production deployment is automated using Ansible and there are some Docker Compose configurations for development.
## Project Structure
- `core/` Contains the Django backend with database models, API endpoints, migrations, API tests, and mail server
functionalities.
- `web/` Contains the Vue.js frontend application.
- `deploy/` Contains deployment configurations and Docker scripts for various development modes.
For more information, see the README.md files in the respective directories.
## Development Modes
There are currently 4 development modes for this Project:
- Frontend-Only
- Backend-API-Only
- Full-Stack-Lite 'dev' (docker)
- **[WIP]** Full-Stack 'testing' (docker)
*Choose the one that is most suited to the feature you want to work on or ist easiest for you to set up ;)*
For all modes it is assumed that you have `git` installed, have cloned the repository and are in the root directory of
the project. Use `git clone https://git.hannover.ccc.de/c3lf/c3lf-system-3.git` to get the official upstream repository.
The required packages for each mode are listed separately and also state the specific package name for Debian 12.
### Frontend-Only
This mode is for developing the frontend only. It uses the vue-cli-service (webpack) to serve the frontend and watches
for changes in the source code to provide hot reloading. The API requests are proxied to the staging backend.
#### Requirements
* Node.js (~20.19.0) (`nodejs`)
* npm (~9.2.0) (`npm`)
*Note: The versions are not strict, but these are tested. Other versions might work as well.*
#### Steps
```bash
cd web
npm intall
npm run serve
```
Now you can access the frontend at `localhost:8080` and start editing the code in the `web` directory.
For more information, see the README.md file in the `web` directory.
### Backend-API-Only
This mode is for developing the backend API only. It also specifically excludes most WebSockets and mail server
functionalities. Use this mode to focus on the backend API and Database models.
#### Requirements
* Python (~3.11) (`python3`)
* pip (`python3-pip`)
* virtualenv (`python3-venv`)
*Note: The versions are not strict, but these are tested. Other versions might work as well.*
#### Steps
```
python -m venv venv
source venv/bin/activate
pip install -r core/requirements.dev.txt
cd core
python manage.py test
```
The tests should run successfully to start and you can now start the TDD workflow by adding new failing tests.
For more information about the backend and TDD, see the README.md file in the `core` directory.
### Full-Stack-Lite 'dev' (docker)
This mode is for developing the both frontend and backend backend at the same time in a containerized environment. It
uses the `docker-compose` command to build and run the application in a container. It specifically excludes all mail
server and most WebSocket functionalities.
#### Requirements
* Docker (`docker.io`)
* Docker Compose (`docker-compose`)
*Note: Depending on your system, the `docker compose` command might be included in general `docker` or `docker-ce`
package, or you might want to use podman instead.*
#### Steps
```bash
docker-compose -f deploy/dev/docker-compose.yml up --build
```
The page should be available at [localhost:8080](http://localhost:8080)
This Mode provides a minimal set of testdata, including a user `testuser` with password `testuser`. The test dataset is
defined in deploy/testdata.py and can be extended there.
You can now edit code in `/web` and `/core` and changes will be applied to the running page as soon as the file is
saved.
For details about each part, read `/web/README.md` and `/core/README.md` respectively. To execute commands in the
container context use 'exec' like
```bash
docker exec -it c3lf-sys3-dev-core-1 ./manage.py test`
```
### Full-Stack 'testing' (docker)
**WORK IN PROGRESS**
*will include postfix, mariadb, redis, nginx and the ability to test sending mails, receiving mail and websocket based
realiteme updates in the frontend. the last step in verification before deploying to the staging system using ansible*
## Online Instances
These are deployed using `deploy/ansible/playbooks/deploy-c3lf-sys3.yml` and follow a specific git branch.
### 'live'
| URL | [c3lf.de](https://c3lf.de) |
|----------------|----------------------------|
| **Branch** | live |
| **Host** | polaris.lab.or.it |
| **Debug Mode** | off |
This is the **'production' system** and should strictly follow the staging system after all changes have been validated.
### 'staging'
| URL | [staging.c3lf.de](https://staging.c3lf.de) |
|----------------|--------------------------------------------|
| **Branch** | testing |
| **Host** | andromeda.lab.or.it |
| **Debug Mode** | on |
This system ist automatically updated by [git.hannover.ccc.de](https://git.hannover.ccc.de/c3lf/c3lf-system-3/) whenever
a commit is pushed to the 'testing' branch and the backend tests passed.
**WARNING: allthough this is the staging system, it is fully functional and contains a copy of the 'production' data, so
do not for example reply to tickets for testing purposes as the system WILL SEND AN EMAIL to the person who originally
created it. If you want to test something like that, first create you own test ticket by sending an email to
`<event>@staging.c3lf.de`**

View file

@ -1,68 +0,0 @@
# Core
This directory contains the backend of the C3LF System3 project, which is built using Django and Django Rest Framework.
## Modules
- `authentication`: Handles user authentication and authorization.
- `files`: Manages file uploads and related operations.
- `inventory`: Handles inventory management, including events, containers and items.
- `mail`: Manages email-related functionalities, including sending and receiving emails.
- `notify_sessions`: Handles real-time notifications and WebSocket sessions.
- `tickets`: Manages the ticketing system for issue tracking.
## Modules Structure
Most modules follow a similar structure, including the following components:
- `<module>/models.py`: Contains the database models for the module.
- `<module>/serializers.py`: Contains the serializers for the module models.
- `<module>/api_<api_version>.py`: Contains the API views and endpoints for the module.
- `<module>/migrations/`: Contains database migration files. Needs to contain an `__init__.py` file to be recognized as
a Python package and automatically migration creation to work.
- `<module>/tests/<api_version>/test_<feature_model_or_testcase>.py`: Contains the test cases for the module.
## Development Setup
follow the instructions under 'Backend-API-Only' or 'Fullstack-Lite' in the root level `README.md` to set up a
development environment.
## Test-Driven Development (TDD) Workflow
The project follows a TDD workflow to ensure code quality and reliability. Here is a step-by-step guide to the TDD
process:
1. **Write a Test**: Start by writing a test case for the new feature or bug fix. Place the test case in the appropriate
module within the `<module>/tests/<api_version>/test_<feature_model_or_testcase>.py` file.
2. **Run the Test**: Execute the test to ensure it fails, confirming that the feature is not yet implemented or the bug
exists.
```bash
python manage.py test
```
3. **Write the Code**: Implement the code required to pass the test. Write the code in the appropriate module within the
project.
4. **Run the Test Again**: Execute the test again to ensure it passes.
```bash
python manage.py test
```
5. **Refactor**: Refactor the code to improve its structure and readability while ensuring that all tests still pass.
6. **Repeat**: Repeat the process for each new feature or bug fix.
## Measuring Test Coverage
The project uses the `coverage` package to measure test coverage. To generate a coverage report, run the following
command:
```bash
coverage run --source='.' manage.py test
coverage report
```
## Additional Information
For more detailed information on the project structure and development modes, refer to the root level `README.md`.

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)

0
core/testdata.py Normal file
View file

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