From 04f42a8747d2bc97d12d862ff5faf94c0077165a Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Sat, 15 Mar 2025 21:59:22 +0100 Subject: [PATCH 01/13] added test containers and items to testdata --- deploy/testdata.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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, From 40eae9187bd98669a03a8e6cb5a2ca73f6a80742 Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Sat, 15 Mar 2025 22:38:42 +0100 Subject: [PATCH 02/13] created missing migration --- .../migrations/0013_alter_statechange_state.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 core/tickets/migrations/0013_alter_statechange_state.py diff --git a/core/tickets/migrations/0013_alter_statechange_state.py b/core/tickets/migrations/0013_alter_statechange_state.py new file mode 100644 index 0000000..6a99ce5 --- /dev/null +++ b/core/tickets/migrations/0013_alter_statechange_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2025-03-15 21:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0012_remove_issuethread_related_items_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='statechange', + name='state', + field=models.CharField(choices=[('pending_new', 'New'), ('pending_open', 'Open'), ('pending_shipping', 'Needs to be shipped'), ('pending_physical_confirmation', 'Needs to be confirmed physically'), ('pending_return', 'Needs to be returned'), ('pending_postponed', 'Postponed'), ('pending_suspected_spam', 'Suspected Spam'), ('waiting_details', 'Waiting for details'), ('waiting_pre_shipping', 'Waiting for Address/Shipping Info'), ('closed_returned', 'Closed: Returned'), ('closed_shipped', 'Closed: Shipped'), ('closed_not_found', 'Closed: Not found'), ('closed_not_our_problem', 'Closed: Not our problem'), ('closed_duplicate', 'Closed: Duplicate'), ('closed_timeout', 'Closed: Timeout'), ('closed_spam', 'Closed: Spam'), ('closed_nothing_missing', 'Closed: Nothing missing'), ('closed_wtf', 'Closed: WTF'), ('found_open', 'Item Found and stored externally'), ('found_closed', 'Item Found and stored externally and closed')], default='pending_new', max_length=255), + ), + ] From 67075c25b721ac8012646bc99f0b238dc799ad5d Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Sat, 15 Mar 2025 22:38:56 +0100 Subject: [PATCH 03/13] created model for shipments --- core/core/settings.py | 1 + core/shipments/__init__.py | 0 core/shipments/api_v2.py | 0 core/shipments/migrations/0001_initial.py | 37 +++++++++++++++++++++++ core/shipments/migrations/__init__.py | 0 core/shipments/models.py | 21 +++++++++++++ 6 files changed, 59 insertions(+) create mode 100644 core/shipments/__init__.py create mode 100644 core/shipments/api_v2.py create mode 100644 core/shipments/migrations/0001_initial.py create mode 100644 core/shipments/migrations/__init__.py create mode 100644 core/shipments/models.py 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/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..e69de29 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/__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..b60f56a --- /dev/null +++ b/core/shipments/models.py @@ -0,0 +1,21 @@ +import uuid + +from django.db import models +from django_softdelete.models import SoftDeleteModel, SoftDeleteManager +from inventory.models import Item +from tickets.models import IssueThread + +class Shipment(SoftDeleteModel): + id = models.AutoField(primary_key=True) + public_secret = models.UUIDField(default = uuid.uuid4) + + 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) + + all_objects = models.Manager() + + def __str__(self): + return '[' + str(self.id) + ']' + self.description \ No newline at end of file From 51ddc8edc3e09d8950a362f5e6245483cce93e10 Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Sat, 15 Mar 2025 22:38:42 +0100 Subject: [PATCH 04/13] created missing migration --- .../migrations/0013_alter_statechange_state.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 core/tickets/migrations/0013_alter_statechange_state.py diff --git a/core/tickets/migrations/0013_alter_statechange_state.py b/core/tickets/migrations/0013_alter_statechange_state.py new file mode 100644 index 0000000..6a99ce5 --- /dev/null +++ b/core/tickets/migrations/0013_alter_statechange_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2025-03-15 21:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0012_remove_issuethread_related_items_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='statechange', + name='state', + field=models.CharField(choices=[('pending_new', 'New'), ('pending_open', 'Open'), ('pending_shipping', 'Needs to be shipped'), ('pending_physical_confirmation', 'Needs to be confirmed physically'), ('pending_return', 'Needs to be returned'), ('pending_postponed', 'Postponed'), ('pending_suspected_spam', 'Suspected Spam'), ('waiting_details', 'Waiting for details'), ('waiting_pre_shipping', 'Waiting for Address/Shipping Info'), ('closed_returned', 'Closed: Returned'), ('closed_shipped', 'Closed: Shipped'), ('closed_not_found', 'Closed: Not found'), ('closed_not_our_problem', 'Closed: Not our problem'), ('closed_duplicate', 'Closed: Duplicate'), ('closed_timeout', 'Closed: Timeout'), ('closed_spam', 'Closed: Spam'), ('closed_nothing_missing', 'Closed: Nothing missing'), ('closed_wtf', 'Closed: WTF'), ('found_open', 'Item Found and stored externally'), ('found_closed', 'Item Found and stored externally and closed')], default='pending_new', max_length=255), + ), + ] From 8d830bda644b9b2c34d8ca3f7a23080149615e02 Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Sat, 15 Mar 2025 22:55:51 +0100 Subject: [PATCH 05/13] added readme for shipment features --- core/shipments/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 core/shipments/README.md diff --git a/core/shipments/README.md b/core/shipments/README.md new file mode 100644 index 0000000..b224fe5 --- /dev/null +++ b/core/shipments/README.md @@ -0,0 +1,18 @@ +# 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) \ No newline at end of file From 756fe4aaad3c29b9dc5bd0fd9c3a89fb070088bd Mon Sep 17 00:00:00 2001 From: jedi Date: Sun, 16 Mar 2025 14:24:44 +0100 Subject: [PATCH 06/13] add README.md --- README.md | 130 +++++++++++++++++++++++++++++++++++++++++++++++ core/README.md | 68 +++++++++++++++++++++++++ core/testdata.py | 0 3 files changed, 198 insertions(+) create mode 100644 README.md create mode 100644 core/README.md delete mode 100644 core/testdata.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fea60a --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# 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* + + diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..f9780e0 --- /dev/null +++ b/core/README.md @@ -0,0 +1,68 @@ +# 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/testdata.py b/core/testdata.py deleted file mode 100644 index e69de29..0000000 From 8d45fef62776d0c41007d1394479fb5655958f6f Mon Sep 17 00:00:00 2001 From: jedi Date: Sun, 16 Mar 2025 15:21:02 +0100 Subject: [PATCH 07/13] add information about the online instances to the README.md --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 3fea60a..4ad064a 100644 --- a/README.md +++ b/README.md @@ -128,3 +128,27 @@ docker exec -it c3lf-sys3-dev-core-1 ./manage.py test` 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. \ No newline at end of file From 9def22a836cfa56e25a3ed4c81a46f8029848d62 Mon Sep 17 00:00:00 2001 From: jedi Date: Sun, 16 Mar 2025 15:31:46 +0100 Subject: [PATCH 08/13] extend information about the online instances to the README.md --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4ad064a..3581cac 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,6 @@ docker exec -it c3lf-sys3-dev-core-1 ./manage.py test` *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. @@ -151,4 +150,9 @@ This is the **'production' system** and should strictly follow the staging syste | **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. \ No newline at end of file +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 From dfdea0158fb3865372fe51984599e9e61980b2c8 Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Wed, 2 Apr 2025 21:53:35 +0200 Subject: [PATCH 09/13] added shipment address --- core/shipments/models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/shipments/models.py b/core/shipments/models.py index b60f56a..9af5edf 100644 --- a/core/shipments/models.py +++ b/core/shipments/models.py @@ -9,6 +9,15 @@ class Shipment(SoftDeleteModel): id = models.AutoField(primary_key=True) public_secret = models.UUIDField(default = uuid.uuid4) + # 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) From 6515ce8efa278f3ac4b4d5c4c5c764e399a99244 Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Fri, 11 Apr 2025 15:54:38 +0200 Subject: [PATCH 10/13] feat(shipments): implement state model and validation for shipment workflow --- core/shipments/README.md | 56 +++++++++++++++- core/shipments/models.py | 138 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/core/shipments/README.md b/core/shipments/README.md index b224fe5..4666acc 100644 --- a/core/shipments/README.md +++ b/core/shipments/README.md @@ -15,4 +15,58 @@ This module handles shipments that are processed by the lost&found team. - **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) \ No newline at end of file +- 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/models.py b/core/shipments/models.py index 9af5edf..b98f88d 100644 --- a/core/shipments/models.py +++ b/core/shipments/models.py @@ -1,13 +1,53 @@ 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) + 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='') @@ -23,8 +63,100 @@ class Shipment(SoftDeleteModel): 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 '[' + str(self.id) + ']' + self.description \ No newline at end of file + 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 From 0e6ebc387f30b2de406c8b306b3abf79abe18f34 Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Fri, 11 Apr 2025 17:32:23 +0200 Subject: [PATCH 11/13] added migration for shipment addresses --- ...ress_supplements_shipment_city_and_more.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 core/shipments/migrations/0002_shipment_address_supplements_shipment_city_and_more.py 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), + ), + ] From 6a5d9549809e13438a85fa61df42e41ba09fb7e7 Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Fri, 11 Apr 2025 21:46:26 +0200 Subject: [PATCH 12/13] Fix shipment API tests and implement state transitions --- core/core/urls.py | 1 + core/shipments/api_v2.py | 108 ++++++++++ core/shipments/tests/__init__.py | 0 core/shipments/tests/v2/__init__.py | 0 core/shipments/tests/v2/test_api.py | 324 ++++++++++++++++++++++++++++ 5 files changed, 433 insertions(+) create mode 100644 core/shipments/tests/__init__.py create mode 100644 core/shipments/tests/v2/__init__.py create mode 100644 core/shipments/tests/v2/test_api.py 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/api_v2.py b/core/shipments/api_v2.py index e69de29..c68adf7 100644 --- a/core/shipments/api_v2.py +++ b/core/shipments/api_v2.py @@ -0,0 +1,108 @@ +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 +from rest_framework import serializers + +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 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) + + +router = routers.SimpleRouter() +router.register(r'shipments', ShipmentViewSet, basename='shipments') + +urlpatterns = router.urls 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..2de38a9 --- /dev/null +++ b/core/shipments/tests/v2/test_api.py @@ -0,0 +1,324 @@ +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) From 3d01f167503611858202a34fcdb8a439a4d38eff Mon Sep 17 00:00:00 2001 From: Jan Felix Wiebe Date: Fri, 11 Apr 2025 22:13:05 +0200 Subject: [PATCH 13/13] added public api for shipments --- core/shipments/api_v2.py | 101 ++++++++++- core/shipments/tests/v2/test_api.py | 271 ++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 2 deletions(-) diff --git a/core/shipments/api_v2.py b/core/shipments/api_v2.py index c68adf7..0c68d0c 100644 --- a/core/shipments/api_v2.py +++ b/core/shipments/api_v2.py @@ -2,8 +2,9 @@ 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 +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 @@ -34,6 +35,32 @@ class ShipmentSerializer(serializers.ModelSerializer): 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() @@ -102,7 +129,77 @@ class ShipmentViewSet(viewsets.ModelViewSet): 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 +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/tests/v2/test_api.py b/core/shipments/tests/v2/test_api.py index 2de38a9..a6ad468 100644 --- a/core/shipments/tests/v2/test_api.py +++ b/core/shipments/tests/v2/test_api.py @@ -322,3 +322,274 @@ class ShipmentApiTest(TestCase): # 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)