Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
44 changes: 44 additions & 0 deletions backend/inventory/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from rest_framework import serializers
from rest_framework.validators import UniqueValidator

from .models import CollectionItem, Location


Expand Down Expand Up @@ -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},
}
289 changes: 289 additions & 0 deletions backend/inventory/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand Down Expand Up @@ -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."""
Expand Down
20 changes: 12 additions & 8 deletions backend/inventory/urls.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)),
]
Loading