Compare commits
9 commits
testing
...
clarity/in
Author | SHA1 | Date | |
---|---|---|---|
3d01f16750 | |||
6a5d954980 | |||
0e6ebc387f | |||
6515ce8efa | |||
dfdea0158f | |||
8d830bda64 | |||
67075c25b7 | |||
40eae9187b | |||
04f42a8747 |
16 changed files with 1152 additions and 227 deletions
158
README.md
158
README.md
|
@ -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`**
|
|
@ -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`.
|
|
@ -70,6 +70,7 @@ INSTALLED_APPS = [
|
|||
'inventory',
|
||||
'mail',
|
||||
'notify_sessions',
|
||||
'shipments'
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
|
|
|
@ -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
72
core/shipments/README.md
Normal 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
|
0
core/shipments/__init__.py
Normal file
0
core/shipments/__init__.py
Normal file
205
core/shipments/api_v2.py
Normal file
205
core/shipments/api_v2.py
Normal 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'),
|
||||
]
|
37
core/shipments/migrations/0001_initial.py
Normal file
37
core/shipments/migrations/0001_initial.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
0
core/shipments/migrations/__init__.py
Normal file
0
core/shipments/migrations/__init__.py
Normal file
162
core/shipments/models.py
Normal file
162
core/shipments/models.py
Normal 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)
|
0
core/shipments/tests/__init__.py
Normal file
0
core/shipments/tests/__init__.py
Normal file
0
core/shipments/tests/v2/__init__.py
Normal file
0
core/shipments/tests/v2/__init__.py
Normal file
595
core/shipments/tests/v2/test_api.py
Normal file
595
core/shipments/tests/v2/test_api.py
Normal 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
0
core/testdata.py
Normal 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,
|
||||
|
|
Loading…
Add table
Reference in a new issue