diff --git a/README.md b/README.md deleted file mode 100644 index 3581cac..0000000 --- a/README.md +++ /dev/null @@ -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 -`@staging.c3lf.de`** \ No newline at end of file diff --git a/core/README.md b/core/README.md deleted file mode 100644 index f9780e0..0000000 --- a/core/README.md +++ /dev/null @@ -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: - -- `/models.py`: Contains the database models for the module. -- `/serializers.py`: Contains the serializers for the module models. -- `/api_.py`: Contains the API views and endpoints for the 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. -- `/tests//test_.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 `/tests//test_.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`. \ No newline at end of file diff --git a/core/core/settings.py b/core/core/settings.py index 805a27b..90e026b 100644 --- a/core/core/settings.py +++ b/core/core/settings.py @@ -70,6 +70,7 @@ INSTALLED_APPS = [ 'inventory', 'mail', 'notify_sessions', + 'shipments' ] REST_FRAMEWORK = { diff --git a/core/core/urls.py b/core/core/urls.py index 2386891..55093e9 100644 --- a/core/core/urls.py +++ b/core/core/urls.py @@ -30,6 +30,7 @@ urlpatterns = [ path('api/2/', include('mail.api_v2')), path('api/2/', include('notify_sessions.api_v2')), path('api/2/', include('authentication.api_v2')), + path('api/2/', include('shipments.api_v2')), path('api/', get_info), path('', include('django_prometheus.urls')), ] diff --git a/core/shipments/README.md b/core/shipments/README.md new file mode 100644 index 0000000..4666acc --- /dev/null +++ b/core/shipments/README.md @@ -0,0 +1,72 @@ +# Shipment Management +## Functional Description +This module handles shipments that are processed by the lost&found team. + +**Feature List** +- Shipment can contain *n* (often one, but a lostee can also be sent multiple items) items +- Shipment is linked to *n* tickets (for informative purposes) +- Shipment holds the address of the parcel +- Shipment holds the dhl voucher used +- On creation of a shipment, the agent can activate the features "adress retrieval", "item validation" and/or tracking + - **address retrieval**: the lostee recieves a email with publicly accessible link, on this page the user can enter his address. + - address sanitation + - address validation + - automatic state change after successful address entry (-> waiting for shipping) + - **item validation**: the lostee recieves a email with publicly accessible link, on this page the user see the images and confirm the items + - automatic state change after confirmation + - **tracking**: the lostee recieves a email with publicly accessible link, on this page the user can enter his address +- Returning parcels could be managed to (e.g. if a shipment is marked as returned, the items will be *unfound* again) + +## State Model +The shipment process follows a defined state model to ensure proper workflow and validation: + +### States +1. **CREATED** (Initial State) + - Shipment is created without address, items, or tickets + - No validation requirements + +2. **PENDING_REVIEW** + - Items have been added to the shipment + - Waiting for requester to verify items and/or address + - Requires specification of what needs to be reviewed: + - Items only + - Address only + - Both items and address + - Validation requirements depend on review type + +3. **READY_FOR_SHIPPING** + - All required reviews completed + - All required fields validated + - Ready for shipping process + +4. **REJECTED** + - Shipment was reviewed and rejected + - Terminal state (no further transitions possible) + +5. **SHIPPED** + - Package has been shipped + - Requires complete address information + - Timestamp of shipping is recorded + +6. **COMPLETED** + - Package has been delivered + - Terminal state (no further transitions possible) + - Timestamp of completion is recorded + +### State Transitions +- CREATED → PENDING_REVIEW (when items are added, requires specification of review type) +- PENDING_REVIEW → READY_FOR_SHIPPING (when approved and all required reviews completed) +- PENDING_REVIEW → REJECTED (when rejected) +- READY_FOR_SHIPPING → SHIPPED (when marked as shipped) +- SHIPPED → COMPLETED (when marked as delivered) + +### Validation Rules +- Review requirement must be specified when entering PENDING_REVIEW state +- Address fields are required for shipping-related states (READY_FOR_SHIPPING, SHIPPED, COMPLETED) +- At least one item is required for any state beyond CREATED +- Invalid state transitions will raise validation errors +- Terminal states (REJECTED, COMPLETED) cannot transition to other states +- Approval requires completion of all specified review requirements: + - For ITEMS review: items must be added + - For ADDRESS review: all address fields must be complete + - For BOTH review: both items and complete address are required \ No newline at end of file diff --git a/core/shipments/__init__.py b/core/shipments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/api_v2.py b/core/shipments/api_v2.py new file mode 100644 index 0000000..0c68d0c --- /dev/null +++ b/core/shipments/api_v2.py @@ -0,0 +1,205 @@ +from django.urls import re_path +from rest_framework import routers, viewsets, status +from rest_framework.decorators import api_view, permission_classes, action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework import serializers +from django.shortcuts import get_object_or_404 + +from .models import Shipment +from inventory.models import Item +from tickets.models import IssueThread + + +class ShipmentSerializer(serializers.ModelSerializer): + related_items = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Item.objects.all(), + required=False + ) + related_tickets = serializers.PrimaryKeyRelatedField( + many=True, + queryset=IssueThread.objects.all(), + required=False + ) + + class Meta: + model = Shipment + fields = [ + 'id', 'public_secret', 'state', 'review_requirement', + 'recipient_name', 'address_supplements', 'street_address', + 'city', 'state_province', 'postal_code', 'country', + 'related_items', 'related_tickets', + 'created_at', 'updated_at', 'shipped_at', 'completed_at' + ] + read_only_fields = ['id', 'public_secret', 'created_at', 'updated_at', 'shipped_at', 'completed_at'] + + +class PublicShipmentSerializer(serializers.ModelSerializer): + related_items = serializers.PrimaryKeyRelatedField( + many=True, + read_only=True + ) + + class Meta: + model = Shipment + fields = [ + 'id', 'public_secret', 'state', 'review_requirement', + 'recipient_name', 'address_supplements', 'street_address', + 'city', 'state_province', 'postal_code', 'country', + 'related_items', 'created_at' + ] + read_only_fields = ['id', 'public_secret', 'state', 'review_requirement', 'created_at'] + + +class ShipmentAddressUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Shipment + fields = [ + 'recipient_name', 'address_supplements', 'street_address', + 'city', 'state_province', 'postal_code', 'country' + ] + + +class ShipmentViewSet(viewsets.ModelViewSet): + serializer_class = ShipmentSerializer + queryset = Shipment.objects.all() + permission_classes = [IsAuthenticated] + + @action(detail=True, methods=['post']) + def add_items(self, request, pk=None): + shipment = self.get_object() + review_requirement = request.data.get('review_requirement') + item_ids = request.data.get('item_ids', []) + + try: + items = Item.objects.filter(id__in=item_ids) + shipment.related_items.add(*items) + shipment.add_items(review_requirement) + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def approve(self, request, pk=None): + shipment = self.get_object() + try: + shipment.approve() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def reject(self, request, pk=None): + shipment = self.get_object() + try: + shipment.reject() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def mark_shipped(self, request, pk=None): + shipment = self.get_object() + try: + # Validate state transition before attempting to mark as shipped + if shipment.state != Shipment.READY_FOR_SHIPPING: + return Response( + {'error': f'Invalid state transition: Cannot mark shipment as shipped from {shipment.state} state. Shipment must be in READY_FOR_SHIPPING state.'}, + status=status.HTTP_400_BAD_REQUEST + ) + shipment.mark_shipped() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def mark_completed(self, request, pk=None): + shipment = self.get_object() + try: + # Validate state transition before attempting to mark as completed + if shipment.state != Shipment.SHIPPED: + return Response( + {'error': f'Invalid state transition: Cannot mark shipment as completed from {shipment.state} state. Shipment must be in SHIPPED state.'}, + status=status.HTTP_400_BAD_REQUEST + ) + shipment.mark_completed() + return Response(ShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +# Public API endpoints for shipment recipients +@api_view(['GET']) +@permission_classes([AllowAny]) +def public_shipment_detail(request, public_secret): + """ + Retrieve shipment details using public_secret. + Only accessible when shipment is in PENDING_REVIEW state. + """ + try: + shipment = Shipment.objects.get(public_secret=public_secret) + + # Only allow access to shipments in PENDING_REVIEW state + if shipment.state != Shipment.PENDING_REVIEW: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = PublicShipmentSerializer(shipment) + return Response(serializer.data) + except Shipment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +@api_view(['POST']) +@permission_classes([AllowAny]) +def public_shipment_update(request, public_secret): + """ + Update shipment with address information and transition state. + Only accessible when shipment is in PENDING_REVIEW state. + """ + try: + shipment = Shipment.objects.get(public_secret=public_secret) + + # Only allow updates to shipments in PENDING_REVIEW state + if shipment.state != Shipment.PENDING_REVIEW: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = ShipmentAddressUpdateSerializer(shipment, data=request.data, partial=True) + if serializer.is_valid(): + # Update address fields + for field, value in serializer.validated_data.items(): + setattr(shipment, field, value) + + # Update the shipment state based on review requirement + try: + if shipment.review_requirement == Shipment.REVIEW_ADDRESS: + # If only address review was required, we can approve directly + shipment.approve() + elif shipment.review_requirement == Shipment.REVIEW_BOTH: + # If both items and address review were required, we need to check if items exist + if not shipment.related_items.exists(): + return Response( + {'error': 'Items must be added before approval'}, + status=status.HTTP_400_BAD_REQUEST + ) + shipment.approve() + else: + # For ITEMS review requirement, we just save the address but don't change state + shipment.save() + + return Response(PublicShipmentSerializer(shipment).data) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Shipment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +router = routers.SimpleRouter() +router.register(r'shipments', ShipmentViewSet, basename='shipments') + +urlpatterns = router.urls + [ + re_path(r'^public/shipments/(?P[0-9a-f-]+)/$', public_shipment_detail, name='public-shipment-detail'), + re_path(r'^public/shipments/(?P[0-9a-f-]+)/update/$', public_shipment_update, name='public-shipment-update'), +] diff --git a/core/shipments/migrations/0001_initial.py b/core/shipments/migrations/0001_initial.py new file mode 100644 index 0000000..eb889e2 --- /dev/null +++ b/core/shipments/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.7 on 2025-03-15 21:37 + +from django.db import migrations, models +import django.db.models.manager +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('inventory', '0007_rename_container_item_container_old_itemplacement_and_more'), + ('tickets', '0013_alter_statechange_state'), + ] + + operations = [ + migrations.CreateModel( + name='Shipment', + fields=[ + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('public_secret', models.UUIDField(default=uuid.uuid4)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ('related_items', models.ManyToManyField(to='inventory.item')), + ('related_tickets', models.ManyToManyField(to='tickets.issuethread')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('all_objects', django.db.models.manager.Manager()), + ], + ), + ] diff --git a/core/shipments/migrations/0002_shipment_address_supplements_shipment_city_and_more.py b/core/shipments/migrations/0002_shipment_address_supplements_shipment_city_and_more.py new file mode 100644 index 0000000..f5df569 --- /dev/null +++ b/core/shipments/migrations/0002_shipment_address_supplements_shipment_city_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 4.2.7 on 2025-04-11 15:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipments', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='shipment', + name='address_supplements', + field=models.TextField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='shipment', + name='city', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='shipment', + name='completed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='shipment', + name='country', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='shipment', + name='postal_code', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='shipment', + name='recipient_name', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='shipment', + name='review_requirement', + field=models.CharField(blank=True, choices=[('ITEMS', 'Items Review Required'), ('ADDRESS', 'Address Review Required'), ('BOTH', 'Both Items and Address Review Required')], max_length=10, null=True), + ), + migrations.AddField( + model_name='shipment', + name='shipped_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='shipment', + name='state', + field=models.CharField(choices=[('CREATED', 'Created'), ('PENDING_REVIEW', 'Pending Review'), ('READY_FOR_SHIPPING', 'Ready for Shipping'), ('REJECTED', 'Rejected'), ('SHIPPED', 'Shipped'), ('COMPLETED', 'Completed')], default='CREATED', max_length=20), + ), + migrations.AddField( + model_name='shipment', + name='state_province', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='shipment', + name='street_address', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/core/shipments/migrations/__init__.py b/core/shipments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/models.py b/core/shipments/models.py new file mode 100644 index 0000000..b98f88d --- /dev/null +++ b/core/shipments/models.py @@ -0,0 +1,162 @@ +import uuid +from django.db import models +from django.core.exceptions import ValidationError +from django.utils import timezone +from django_softdelete.models import SoftDeleteModel, SoftDeleteManager +from inventory.models import Item +from tickets.models import IssueThread + +class Shipment(SoftDeleteModel): + # State choices + CREATED = 'CREATED' + PENDING_REVIEW = 'PENDING_REVIEW' + READY_FOR_SHIPPING = 'READY_FOR_SHIPPING' + REJECTED = 'REJECTED' + SHIPPED = 'SHIPPED' + COMPLETED = 'COMPLETED' + + SHIPMENT_STATES = [ + (CREATED, 'Created'), + (PENDING_REVIEW, 'Pending Review'), + (READY_FOR_SHIPPING, 'Ready for Shipping'), + (REJECTED, 'Rejected'), + (SHIPPED, 'Shipped'), + (COMPLETED, 'Completed'), + ] + + # Review requirement choices + REVIEW_ITEMS = 'ITEMS' + REVIEW_ADDRESS = 'ADDRESS' + REVIEW_BOTH = 'BOTH' + + REVIEW_REQUIREMENTS = [ + (REVIEW_ITEMS, 'Items Review Required'), + (REVIEW_ADDRESS, 'Address Review Required'), + (REVIEW_BOTH, 'Both Items and Address Review Required'), + ] + + id = models.AutoField(primary_key=True) + public_secret = models.UUIDField(default=uuid.uuid4) + state = models.CharField( + max_length=20, + choices=SHIPMENT_STATES, + default=CREATED + ) + review_requirement = models.CharField( + max_length=10, + choices=REVIEW_REQUIREMENTS, + null=True, + blank=True + ) + + # Shipping address fields + recipient_name = models.CharField(max_length=255, blank=True, default='') + address_supplements = models.TextField(max_length=255, blank=True, default='') + street_address = models.CharField(max_length=255, blank=True, default='') + city = models.CharField(max_length=100, blank=True, default='') + state_province = models.CharField(max_length=100, blank=True, default='') + postal_code = models.CharField(max_length=20, blank=True, default='') + country = models.CharField(max_length=100, blank=True, default='') + + related_items = models.ManyToManyField(Item) + related_tickets = models.ManyToManyField(IssueThread) + + created_at = models.DateTimeField(null=True, auto_now_add=True) + updated_at = models.DateTimeField(blank=True, null=True) + shipped_at = models.DateTimeField(blank=True, null=True) + completed_at = models.DateTimeField(blank=True, null=True) + + all_objects = models.Manager() + + def __str__(self): + return f'[{self.id}] {self.state}' + + def clean(self): + """Validate state transitions and required fields""" + if not self.pk: # New instance + return + + old_instance = Shipment.objects.get(pk=self.pk) + + # Validate state transitions + valid_transitions = { + self.CREATED: [self.PENDING_REVIEW], + self.PENDING_REVIEW: [self.READY_FOR_SHIPPING, self.REJECTED], + self.READY_FOR_SHIPPING: [self.SHIPPED], + self.SHIPPED: [self.COMPLETED], + self.REJECTED: [], # No transitions from rejected + self.COMPLETED: [], # No transitions from completed + } + + if (self.state != old_instance.state and + self.state not in valid_transitions[old_instance.state]): + raise ValidationError(f'Invalid state transition from {old_instance.state} to {self.state}') + + # Validate review requirement when entering PENDING_REVIEW + if self.state == self.PENDING_REVIEW and old_instance.state == self.CREATED: + if not self.review_requirement: + raise ValidationError('Review requirement must be specified when entering PENDING_REVIEW state') + + # Validate required fields based on state and review requirement + if self.state in [self.READY_FOR_SHIPPING, self.SHIPPED, self.COMPLETED]: + if not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('All address fields are required for shipping') + + if self.state != self.CREATED and not self.related_items.exists(): + raise ValidationError('Shipment must have at least one item') + + def add_items(self, review_requirement): + """Move shipment to PENDING_REVIEW after items are added""" + if self.state == self.CREATED and self.related_items.exists(): + if not review_requirement: + raise ValidationError('Review requirement must be specified when adding items') + if review_requirement not in [self.REVIEW_ITEMS, self.REVIEW_ADDRESS, self.REVIEW_BOTH]: + raise ValidationError(f'Invalid review requirement: {review_requirement}') + + self.review_requirement = review_requirement + self.state = self.PENDING_REVIEW + self.save() + + def approve(self): + """Approve shipment for shipping""" + if self.state == self.PENDING_REVIEW: + # Validate that all required reviews are completed + if self.review_requirement == self.REVIEW_ITEMS and not self.related_items.exists(): + raise ValidationError('Items must be added before approval') + elif self.review_requirement == self.REVIEW_ADDRESS and not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('Address must be complete before approval') + elif self.review_requirement == self.REVIEW_BOTH: + if not self.related_items.exists(): + raise ValidationError('Items must be added before approval') + if not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('Address must be complete before approval') + + self.state = self.READY_FOR_SHIPPING + self.save() + + def reject(self): + """Reject shipment""" + if self.state == self.PENDING_REVIEW: + self.state = self.REJECTED + self.save() + + def mark_shipped(self): + """Mark shipment as shipped""" + if self.state == self.READY_FOR_SHIPPING: + self.state = self.SHIPPED + self.shipped_at = timezone.now() + self.save() + + def mark_completed(self): + """Mark shipment as completed""" + if self.state == self.SHIPPED: + self.state = self.COMPLETED + self.completed_at = timezone.now() + self.save() + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) \ No newline at end of file diff --git a/core/shipments/tests/__init__.py b/core/shipments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/tests/v2/__init__.py b/core/shipments/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/shipments/tests/v2/test_api.py b/core/shipments/tests/v2/test_api.py new file mode 100644 index 0000000..a6ad468 --- /dev/null +++ b/core/shipments/tests/v2/test_api.py @@ -0,0 +1,595 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken +from django.utils import timezone + +from authentication.models import ExtendedUser +from inventory.models import Event, Item, Container +from tickets.models import IssueThread +from shipments.models import Shipment + + +class ShipmentApiTest(TestCase): + def setUp(self): + super().setUp() + # Create a test user with all permissions + self.user = ExtendedUser.objects.create_user('testuser', 'test@example.com', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + + # Create authentication token + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + # Create test event and container + self.event = Event.objects.create(slug='test-event', name='Test Event') + self.container = Container.objects.create(name='Test Container') + + # Create test items with container + self.item1 = Item.objects.create(description='Test Item 1', event=self.event, container=self.container) + self.item2 = Item.objects.create(description='Test Item 2', event=self.event, container=self.container) + + # Create test ticket + self.ticket = IssueThread.objects.create(name='Test Ticket') + + def test_list_shipments_empty(self): + """Test listing shipments when none exist""" + response = self.client.get('/api/2/shipments/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_create_shipment(self): + """Test creating a new shipment""" + data = { + 'recipient_name': 'John Smith', + 'street_address': '123 Oxford Street', + 'city': 'London', + 'state_province': 'Greater London', + 'postal_code': 'W1D 2HG', + 'country': 'United Kingdom', + 'related_items': [], + 'related_tickets': [] + } + response = self.client.post('/api/2/shipments/', data, content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['recipient_name'], 'John Smith') + self.assertEqual(response.json()['state'], Shipment.CREATED) + self.assertEqual(response.json()['review_requirement'], None) + + # Retrieve the created shipment and verify its details + shipment_id = response.json()['id'] + get_response = self.client.get(f'/api/2/shipments/{shipment_id}/') + self.assertEqual(get_response.status_code, 200) + self.assertEqual(get_response.json()['id'], shipment_id) + self.assertEqual(get_response.json()['recipient_name'], 'John Smith') + self.assertEqual(get_response.json()['street_address'], '123 Oxford Street') + self.assertEqual(get_response.json()['city'], 'London') + self.assertEqual(get_response.json()['state_province'], 'Greater London') + self.assertEqual(get_response.json()['postal_code'], 'W1D 2HG') + self.assertEqual(get_response.json()['country'], 'United Kingdom') + self.assertEqual(get_response.json()['state'], Shipment.CREATED) + self.assertEqual(get_response.json()['review_requirement'], None) + + def test_get_shipment(self): + """Test retrieving a specific shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Maria Garcia', + street_address='Calle Gran Via 28', + city='Madrid', + state_province='Madrid', + postal_code='28013', + country='Spain' + ) + + response = self.client.get(f'/api/2/shipments/{shipment.id}/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], shipment.id) + self.assertEqual(response.json()['recipient_name'], 'Maria Garcia') + + def test_update_shipment(self): + """Test updating a shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Hans Mueller', + street_address='Kurfürstendamm 216', + city='Berlin', + state_province='Berlin', + postal_code='10719', + country='Germany' + ) + + data = { + 'recipient_name': 'Hans-Jürgen Mueller', + 'address_supplements': 'Apartment 4B' + } + response = self.client.patch(f'/api/2/shipments/{shipment.id}/', data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['recipient_name'], 'Hans-Jürgen Mueller') + self.assertEqual(response.json()['address_supplements'], 'Apartment 4B') + + def test_delete_shipment(self): + """Test deleting a shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Sophie Dubois', + street_address='15 Rue de Rivoli', + city='Paris', + state_province='Île-de-France', + postal_code='75001', + country='France' + ) + + response = self.client.delete(f'/api/2/shipments/{shipment.id}/') + self.assertEqual(response.status_code, 204) + + # Verify it's soft deleted - use all_objects manager to see deleted items + self.assertTrue(Shipment.all_objects.filter(id=shipment.id).exists()) + # Check if it's marked as deleted + shipment.refresh_from_db() + self.assertIsNotNone(shipment.deleted_at) + + def test_add_items(self): + """Test adding items to a shipment""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Alessandro Romano', + street_address='Via del Corso 123', + city='Rome', + state_province='Lazio', + postal_code='00186', + country='Italy' + ) + + data = { + 'item_ids': [self.item1.id, self.item2.id], + 'review_requirement': Shipment.REVIEW_ITEMS + } + response = self.client.post(f'/api/2/shipments/{shipment.id}/add_items/', data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.PENDING_REVIEW) + self.assertEqual(response.json()['review_requirement'], Shipment.REVIEW_ITEMS) + + # Verify items were added + shipment.refresh_from_db() + self.assertEqual(shipment.related_items.count(), 2) + + def test_approve_shipment(self): + """Test approving a shipment""" + # Create a shipment and add items + shipment = Shipment.objects.create( + recipient_name='Yuki Tanaka', + street_address='1-1-1 Shibuya', + city='Tokyo', + state_province='Tokyo', + postal_code='150-0002', + country='Japan' + ) + shipment.related_items.add(self.item1) + shipment.review_requirement = Shipment.REVIEW_ITEMS + shipment.state = Shipment.PENDING_REVIEW + shipment.save() + + response = self.client.post(f'/api/2/shipments/{shipment.id}/approve/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.READY_FOR_SHIPPING) + + def test_reject_shipment(self): + """Test rejecting a shipment""" + # Create a shipment and add items + shipment = Shipment.objects.create( + recipient_name='Carlos Rodriguez', + street_address='Avenida Insurgentes Sur 1602', + city='Mexico City', + state_province='CDMX', + postal_code='03940', + country='Mexico' + ) + shipment.related_items.add(self.item1) + shipment.review_requirement = Shipment.REVIEW_ITEMS + shipment.state = Shipment.PENDING_REVIEW + shipment.save() + + response = self.client.post(f'/api/2/shipments/{shipment.id}/reject/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.REJECTED) + + def test_mark_shipped(self): + """Test marking a shipment as shipped""" + # Create a shipment and set it to READY_FOR_SHIPPING + shipment = Shipment.objects.create( + recipient_name='Anna Kowalski', + street_address='ul. Marszałkowska 126', + city='Warsaw', + state_province='Mazowieckie', + postal_code='00-008', + country='Poland', + state=Shipment.READY_FOR_SHIPPING + ) + shipment.related_items.add(self.item1) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/mark_shipped/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.SHIPPED) + self.assertIsNotNone(response.json()['shipped_at']) + + def test_mark_completed(self): + """Test marking a shipment as completed""" + # Create a shipment and set it to SHIPPED + shipment = Shipment.objects.create( + recipient_name='Lars Andersen', + street_address='Strøget 1', + city='Copenhagen', + state_province='Capital Region', + postal_code='1095', + country='Denmark', + state=Shipment.SHIPPED, + shipped_at=timezone.now() + ) + shipment.related_items.add(self.item1) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/mark_completed/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.COMPLETED) + self.assertIsNotNone(response.json()['completed_at']) + + def test_invalid_state_transition(self): + """Test that invalid state transitions are rejected""" + # Create a shipment in CREATED state + shipment = Shipment.objects.create( + recipient_name='Ahmed Hassan', + street_address='26 July Street', + city='Cairo', + state_province='Cairo Governorate', + postal_code='11511', + country='Egypt' + ) + + # Try to mark it as shipped directly (invalid transition) + response = self.client.post(f'/api/2/shipments/{shipment.id}/mark_shipped/') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + self.assertIn('Invalid state transition', response.json()['error']) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.CREATED) + + def test_add_items_without_review_requirement(self): + """Test adding items without specifying review requirement""" + # Create a shipment first + shipment = Shipment.objects.create( + recipient_name='Wei Chen', + street_address='88 Century Avenue', + city='Shanghai', + state_province='Shanghai', + postal_code='200120', + country='China' + ) + + data = { + 'item_ids': [self.item1.id, self.item2.id] + } + response = self.client.post(f'/api/2/shipments/{shipment.id}/add_items/', data, content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.CREATED) + + def test_approve_without_items(self): + """Test approving a shipment without items""" + # Create a shipment with REVIEW_ITEMS requirement + shipment = Shipment.objects.create( + recipient_name='Raj Patel', + street_address='123 MG Road', + city='Mumbai', + state_province='Maharashtra', + postal_code='400001', + country='India', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ITEMS + ) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/approve/') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW) + + def test_approve_without_address(self): + """Test approving a shipment without complete address""" + # Create a shipment with REVIEW_ADDRESS requirement + shipment = Shipment.objects.create( + recipient_name='Sofia Oliveira', + street_address='Avenida Paulista 1000', + city='São Paulo', + state_province='São Paulo', + postal_code='01310-100', + country='', # Missing country + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ADDRESS + ) + shipment.related_items.add(self.item1) + + response = self.client.post(f'/api/2/shipments/{shipment.id}/approve/') + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW) + + def test_public_shipment_detail_success(self): + """Test retrieving shipment details via public API with valid public_secret""" + # Create a shipment in PENDING_REVIEW state + shipment = Shipment.objects.create( + recipient_name='John Doe', + street_address='123 Main St', + city='Anytown', + state_province='State', + postal_code='12345', + country='Country', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ADDRESS + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test retrieving shipment details + response = public_client.get(f'/api/2/public/shipments/{shipment.public_secret}/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], shipment.id) + self.assertEqual(response.json()['recipient_name'], 'John Doe') + self.assertEqual(response.json()['state'], Shipment.PENDING_REVIEW) + self.assertEqual(len(response.json()['related_items']), 1) + + def test_public_shipment_detail_not_found(self): + """Test retrieving shipment details with non-existent public_secret""" + # Create a client without authentication + public_client = Client() + + # Test with non-existent public_secret + response = public_client.get('/api/2/public/shipments/00000000-0000-0000-0000-000000000000/') + self.assertEqual(response.status_code, 404) + + def test_public_shipment_detail_wrong_state(self): + """Test retrieving shipment details when shipment is not in PENDING_REVIEW state""" + # Create a shipment in CREATED state + shipment = Shipment.objects.create( + recipient_name='Jane Smith', + street_address='456 Oak Ave', + city='Somewhere', + state_province='Province', + postal_code='67890', + country='Nation', + state=Shipment.CREATED + ) + + # Create a client without authentication + public_client = Client() + + # Test retrieving shipment details + response = public_client.get(f'/api/2/public/shipments/{shipment.public_secret}/') + self.assertEqual(response.status_code, 404) + + def test_public_shipment_update_success_address_only(self): + """Test updating shipment with address information when review_requirement is ADDRESS""" + # Create a shipment in PENDING_REVIEW state with ADDRESS review requirement + shipment = Shipment.objects.create( + recipient_name='Alice Johnson', + street_address='789 Pine Rd', + city='Nowhere', + state_province='Region', + postal_code='13579', + country='Land', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ADDRESS + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Alice Johnson', + 'street_address': '789 Pine Rd, Apt 4B', + 'city': 'Nowhere', + 'state_province': 'Region', + 'postal_code': '13579', + 'country': 'Land' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.READY_FOR_SHIPPING) + self.assertEqual(response.json()['street_address'], '789 Pine Rd, Apt 4B') + + # Verify state has changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.READY_FOR_SHIPPING) + + def test_public_shipment_update_success_both_review(self): + """Test updating shipment with address information when review_requirement is BOTH""" + # Create a shipment in PENDING_REVIEW state with BOTH review requirement + shipment = Shipment.objects.create( + recipient_name='Bob Wilson', + street_address='321 Elm St', + city='Elsewhere', + state_province='Territory', + postal_code='24680', + country='Realm', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_BOTH + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Bob Wilson', + 'street_address': '321 Elm St, Suite 7', + 'city': 'Elsewhere', + 'state_province': 'Territory', + 'postal_code': '24680', + 'country': 'Realm' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.READY_FOR_SHIPPING) + self.assertEqual(response.json()['street_address'], '321 Elm St, Suite 7') + + # Verify state has changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.READY_FOR_SHIPPING) + + def test_public_shipment_update_success_items_only(self): + """Test updating shipment with address information when review_requirement is ITEMS""" + # Create a shipment in PENDING_REVIEW state with ITEMS review requirement + shipment = Shipment.objects.create( + recipient_name='Carol Davis', + street_address='654 Maple Dr', + city='Anywhere', + state_province='District', + postal_code='97531', + country='Kingdom', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_ITEMS + ) + shipment.related_items.add(self.item1) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Carol Davis', + 'street_address': '654 Maple Dr, Unit 3', + 'city': 'Anywhere', + 'state_province': 'District', + 'postal_code': '97531', + 'country': 'Kingdom' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], Shipment.PENDING_REVIEW) # State should not change + self.assertEqual(response.json()['street_address'], '654 Maple Dr, Unit 3') + + # Verify state has not changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW) + + def test_public_shipment_update_not_found(self): + """Test updating shipment with non-existent public_secret""" + # Create a client without authentication + public_client = Client() + + # Test with non-existent public_secret + data = { + 'recipient_name': 'Unknown Person', + 'street_address': '123 Fake St', + 'city': 'Fake City', + 'state_province': 'Fake State', + 'postal_code': '12345', + 'country': 'Fake Country' + } + response = public_client.post( + '/api/2/public/shipments/00000000-0000-0000-0000-000000000000/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 404) + + def test_public_shipment_update_wrong_state(self): + """Test updating shipment when it's not in PENDING_REVIEW state""" + # Create a shipment in CREATED state + shipment = Shipment.objects.create( + recipient_name='David Lee', + street_address='987 Cedar Ln', + city='Nowhere', + state_province='Province', + postal_code='13579', + country='Country', + state=Shipment.CREATED + ) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment + data = { + 'recipient_name': 'David Lee', + 'street_address': '987 Cedar Ln, Apt 2C', + 'city': 'Nowhere', + 'state_province': 'Province', + 'postal_code': '13579', + 'country': 'Country' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 404) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.CREATED) + + def test_public_shipment_update_both_review_missing_items(self): + """Test updating shipment with BOTH review requirement when items are missing""" + # Create a shipment in PENDING_REVIEW state with BOTH review requirement but no items + shipment = Shipment.objects.create( + recipient_name='Eve Brown', + street_address='147 Birch Ave', + city='Somewhere', + state_province='Region', + postal_code='24680', + country='Land', + state=Shipment.PENDING_REVIEW, + review_requirement=Shipment.REVIEW_BOTH + ) + + # Create a client without authentication + public_client = Client() + + # Test updating shipment with address information + data = { + 'recipient_name': 'Eve Brown', + 'street_address': '147 Birch Ave, Suite 5', + 'city': 'Somewhere', + 'state_province': 'Region', + 'postal_code': '24680', + 'country': 'Land' + } + response = public_client.post( + f'/api/2/public/shipments/{shipment.public_secret}/update/', + data, + content_type='application/json' + ) + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + self.assertIn('Items must be added before approval', response.json()['error']) + + # Verify state hasn't changed + shipment.refresh_from_db() + self.assertEqual(shipment.state, Shipment.PENDING_REVIEW) diff --git a/core/testdata.py b/core/testdata.py new file mode 100644 index 0000000..e69de29 diff --git a/deploy/testdata.py b/deploy/testdata.py index dca385f..6cb3b33 100644 --- a/deploy/testdata.py +++ b/deploy/testdata.py @@ -3,7 +3,7 @@ import os def setup(): from authentication.models import ExtendedUser, EventPermission - from inventory.models import Event + from inventory.models import Event, Container, Item from django.contrib.auth.models import Permission, Group permissions = ['add_item', 'view_item', 'view_file', 'delete_item', 'change_item'] if not ExtendedUser.objects.filter(username='admin').exists(): @@ -38,6 +38,16 @@ def setup(): start='2024-12-18 00:00:00.000000', end='2024-12-27 00:00:00.000000', pre_start='2024-12-31 00:00:00.000000', post_end='2025-01-04 00:00:00.000000')[ 0] + + container1 ,_= Container.objects.get_or_create(id=1, name='Box1') + container2 ,_= Container.objects.get_or_create(id=2, name='Box2') + + testitem1 ,_= Item.objects.get_or_create(id=1, event=event1, description="Test item 1",uid_deprecated=111) + testitem1.container = container1 + testitem2 ,_= Item.objects.get_or_create(id=2, event=event2, description="Test item 2",uid_deprecated=112) + testitem2.container = container1 + testitem3 ,_= Item.objects.get_or_create(id=3, event=event2, description="Test item 3",uid_deprecated=113) + testitem3.container = container2 # for permission in permissions: # EventPermission.objects.create(event=event_37c3, user=foo,