diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..e5b8847 Binary files /dev/null and b/.DS_Store differ diff --git a/backend/inventory/serializers.py b/backend/inventory/serializers.py index b9c3bbb..62129d5 100644 --- a/backend/inventory/serializers.py +++ b/backend/inventory/serializers.py @@ -1,4 +1,6 @@ from rest_framework import serializers +from rest_framework.validators import UniqueValidator + from .models import CollectionItem, Location @@ -44,3 +46,45 @@ class Meta: "is_on_floor", "current_location", ] + + +class AdminCollectionItemSerializer(serializers.ModelSerializer): + """ + Writable serializer for admin/volunteer create and update. + Accepts item_code, title, platform, description, current_location (ID), is_public_visible, is_on_floor. + Returns nested current_location object in responses. + """ + + current_location = serializers.PrimaryKeyRelatedField(queryset=Location.objects.all(), required=True) + + def to_representation(self, instance): + ret = super().to_representation(instance) + if instance.current_location: + ret["current_location"] = LocationSerializer(instance.current_location).data + return ret + + class Meta: + model = CollectionItem + fields = [ + "id", + "item_code", + "title", + "platform", + "description", + "current_location", + "is_public_visible", + "is_on_floor", + ] + read_only_fields = ["id"] + extra_kwargs = { + "item_code": { + "required": True, + "validators": [ + UniqueValidator( + queryset=CollectionItem.objects.all(), + message="A collection item with this barcode/UUID already exists.", + ) + ], + }, + "title": {"required": True}, + } diff --git a/backend/inventory/tests.py b/backend/inventory/tests.py index ffb07c2..db1f71f 100644 --- a/backend/inventory/tests.py +++ b/backend/inventory/tests.py @@ -89,6 +89,60 @@ def test_data(client, floor_location, storage_location, public_item_snes, public } +@pytest.fixture +def admin_user(): + """Create admin user for testing.""" + return User.objects.create_user( + email="admin@inventory.com", + name="Admin User", + password="adminpass", + role="ADMIN", + ) + + +@pytest.fixture +def volunteer_user(): + """Create volunteer user for testing.""" + return User.objects.create_user( + email="volunteer@inventory.com", + name="Volunteer User", + password="volpass", + role="VOLUNTEER", + ) + + +def get_admin_token(client): + """Login as admin and return access token.""" + res = client.post( + "/api/auth/login/", + data=json.dumps({"email": "admin@inventory.com", "password": "adminpass"}), + content_type="application/json", + ) + if res.status_code != 200: + return None + try: + data = json.loads(res.content) + except (TypeError, ValueError): + return None + return data.get("access") + + +def get_volunteer_token(client): + """Login as volunteer and return access token.""" + res = client.post( + "/api/auth/login/", + data=json.dumps({"email": "volunteer@inventory.com", "password": "volpass"}), + content_type="application/json", + ) + if res.status_code != 200: + return None + try: + data = json.loads(res.content) + except (TypeError, ValueError): + return None + return data.get("access") + + # ============================================================================ # HELPER FUNCTIONS # ============================================================================ @@ -468,6 +522,241 @@ def test_retrieve_nonexistent_item_returns_404(client): assert response.status_code == status.HTTP_404_NOT_FOUND +# ============================================================================ +# TESTS FOR POST /api/inventory/items/ (Create - admin/volunteer) +# ============================================================================ + + +@pytest.mark.django_db +def test_post_create_item_success(client, volunteer_user, storage_location): + """Test volunteer can create a new item via POST.""" + token = get_volunteer_token(client) + assert token is not None + + payload = { + "item_code": "NEW001", + "title": "New Game", + "platform": "SNES", + "description": "A new addition", + "current_location": storage_location.id, + "is_public_visible": True, + "is_on_floor": False, + } + response = client.post( + "/api/inventory/items/", + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_201_CREATED + data = json.loads(response.content) + assert data["item_code"] == "NEW001" + assert data["title"] == "New Game" + assert data["platform"] == "SNES" + assert CollectionItem.objects.filter(item_code="NEW001").exists() + + +@pytest.mark.django_db +def test_post_create_item_appears_immediately_in_list(client, volunteer_user, storage_location): + """New items created via API appear immediately in lists (acceptance criteria).""" + token = get_volunteer_token(client) + + payload = { + "item_code": "IMMEDIATE001", + "title": "Immediate Item", + "platform": "PS2", + "current_location": storage_location.id, + "is_public_visible": True, + "is_on_floor": False, + } + create_res = client.post( + "/api/inventory/items/", + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert create_res.status_code == status.HTTP_201_CREATED + + list_res = client.get("/api/inventory/public/items/") + assert list_res.status_code == status.HTTP_200_OK + items = get_items_from_response(list_res) + item_codes = [item["item_code"] for item in items] + assert "IMMEDIATE001" in item_codes + + +@pytest.mark.django_db +def test_post_invalid_data_missing_title_rejected(client, volunteer_user, storage_location): + """Invalid data (e.g., missing title) is rejected (acceptance criteria).""" + token = get_volunteer_token(client) + + payload = { + "item_code": "NO_TITLE001", + "platform": "SNES", + "current_location": storage_location.id, + "is_public_visible": True, + } + response = client.post( + "/api/inventory/items/", + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = json.loads(response.content) + assert "title" in data + + +@pytest.mark.django_db +def test_post_duplicate_item_code_rejected(client, volunteer_user, storage_location): + """Creating an item with duplicate barcode/UUID returns 400 with validation message.""" + token = get_volunteer_token(client) + + # Insert first item into test DB + CollectionItem.objects.create( + item_code="DUP001", + title="First Item", + platform="SNES", + current_location=storage_location, + is_public_visible=True, + is_on_floor=False, + ) + + # Try to create another item with same barcode + payload = { + "item_code": "DUP001", + "title": "Duplicate Item", + "platform": "PS2", + "current_location": storage_location.id, + "is_public_visible": True, + "is_on_floor": False, + } + response = client.post( + "/api/inventory/items/", + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = json.loads(response.content) + assert "item_code" in data + assert "A collection item with this barcode/UUID already exists." in str(data["item_code"]) + + +# ============================================================================ +# TESTS FOR PUT/PATCH /api/inventory/items/{id}/ (Edit metadata - admin/volunteer) +# ============================================================================ + + +@pytest.mark.django_db +def test_put_edit_metadata_success(client, volunteer_user, public_item_snes, floor_location): + """Test volunteer can edit metadata (fix typo, change platform) via PATCH.""" + token = get_volunteer_token(client) + + payload = { + "title": "Super Mario World - Fixed", + "platform": "SNES", + "description": "Updated notes", + "current_location": floor_location.id, + "is_public_visible": True, + "is_on_floor": True, + } + response = client.patch( + f"/api/inventory/items/{public_item_snes.id}/", + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_200_OK + data = json.loads(response.content) + assert data["title"] == "Super Mario World - Fixed" + assert data["description"] == "Updated notes" + + public_item_snes.refresh_from_db() + assert public_item_snes.title == "Super Mario World - Fixed" + + +@pytest.mark.django_db +def test_put_partial_update_only_description(client, volunteer_user, public_item_snes): + """Test PATCH can update only description without sending other required fields.""" + token = get_volunteer_token(client) + original_title = public_item_snes.title + + payload = {"description": "Fixed typo in notes"} + response = client.patch( + f"/api/inventory/items/{public_item_snes.id}/", + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_200_OK + data = json.loads(response.content) + assert data["title"] == original_title + assert data["description"] == "Fixed typo in notes" + + +# ============================================================================ +# TESTS FOR DELETE /api/inventory/items/{id}/ (Soft delete - admin only) +# ============================================================================ + + +@pytest.mark.django_db +def test_delete_soft_delete_success(client, admin_user, public_item_snes): + """Test admin can soft delete (archive) an item via DELETE.""" + token = get_admin_token(client) + item_id = public_item_snes.id + + response = client.delete( + f"/api/inventory/items/{item_id}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + public_item_snes.refresh_from_db() + assert public_item_snes.is_public_visible is False + assert CollectionItem.objects.filter(id=item_id).exists() + + +@pytest.mark.django_db +def test_delete_archived_hidden_from_public_but_in_db(client, admin_user, public_item_snes): + """Archived items hidden from Public Catalogue but remain in database (acceptance criteria).""" + token = get_admin_token(client) + item_id = public_item_snes.id + item_code = public_item_snes.item_code + + response = client.delete( + f"/api/inventory/items/{item_id}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + public_res = client.get("/api/inventory/public/items/") + items = get_items_from_response(public_res) + item_codes_list = [item["item_code"] for item in items] + assert item_code not in item_codes_list + + assert CollectionItem.objects.filter(id=item_id).exists() + archived = CollectionItem.objects.get(id=item_id) + assert archived.is_public_visible is False + + +@pytest.mark.django_db +def test_delete_volunteer_forbidden(client, volunteer_user, public_item_snes): + """DELETE should only be accessible to admins, not volunteers.""" + token = get_volunteer_token(client) + response = client.delete( + f"/api/inventory/items/{public_item_snes.id}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + public_item_snes.refresh_from_db() + assert public_item_snes.is_public_visible is True + + @pytest.mark.django_db class TestGetCurrentLocation: """Tests for get_current_location algorithm validating location-changing events.""" diff --git a/backend/inventory/urls.py b/backend/inventory/urls.py index fcb4be8..ae5580a 100644 --- a/backend/inventory/urls.py +++ b/backend/inventory/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import PublicCollectionItemViewSet +from .views import PublicCollectionItemViewSet, AdminCollectionItemViewSet # from .views import InventoryItemViewSet @@ -16,18 +16,22 @@ # path('', include(router.urls)), # ] -# This will create the following endpoints: +# Public catalogue (read-only, no auth): +# GET /api/inventory/public/items/ - List public items (filter/search) +# GET /api/inventory/public/items/{id}/ - Retrieve public item detail +# +# Admin/volunteer CRUD (auth required): # GET /api/inventory/items/ - List all items # POST /api/inventory/items/ - Create a new item # GET /api/inventory/items/{id}/ - Retrieve a specific item -# PUT /api/inventory/items/{id}/ - Update an item -# PATCH /api/inventory/items/{id}/ - Partial update -# DELETE /api/inventory/items/{id}/ - Delete an item -# GET /api/inventory/items/low_stock/ - Custom action (if defined) +# PUT /api/inventory/items/{id}/ - Full update +# PATCH /api/inventory/items/{id}/ - Partial update +# DELETE /api/inventory/items/{id}/ - Soft delete (admin only) router = DefaultRouter() -router.register(r"items", PublicCollectionItemViewSet, basename="public-item") +router.register(r"public/items", PublicCollectionItemViewSet, basename="public-item") +router.register(r"items", AdminCollectionItemViewSet, basename="admin-item") urlpatterns = [ - path("public/", include(router.urls)), + path("", include(router.urls)), ] diff --git a/backend/inventory/views.py b/backend/inventory/views.py index abc7046..3684d30 100644 --- a/backend/inventory/views.py +++ b/backend/inventory/views.py @@ -1,6 +1,9 @@ -from rest_framework import viewsets, permissions, filters +from rest_framework import viewsets, permissions, filters, status +from rest_framework.response import Response +from users.permissions import IsAdmin, IsVolunteer + from .models import CollectionItem -from .serializers import PublicCollectionItemSerializer +from .serializers import PublicCollectionItemSerializer, AdminCollectionItemSerializer class PublicCollectionItemViewSet(viewsets.ReadOnlyModelViewSet): @@ -45,3 +48,28 @@ def get_queryset(self): # Invalid value: skip filtering on is_on_floor (ignore invalid input) return queryset + + +class AdminCollectionItemViewSet(viewsets.ModelViewSet): + """ + Admin/volunteer ViewSet for managing collection items. + Supports POST, PUT, PATCH, DELETE. + Only accessible to users with ADMIN or Volunteers role depending on operation with + destory being Admin Only + """ + + queryset = CollectionItem.objects.all().select_related("current_location") + serializer_class = AdminCollectionItemSerializer + permission_classes = [IsVolunteer] + + def get_permissions(self): + if self.action == "destroy": + return [IsAdmin()] + return [IsVolunteer()] + + def destroy(self, request, *args, **kwargs): + """Soft delete: set is_public_visible=False instead of removing from DB.""" + instance = self.get_object() + instance.is_public_visible = False + instance.save(update_fields=["is_public_visible", "updated_at"]) + return Response(status=status.HTTP_204_NO_CONTENT)