Skip to content
Open
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
149 changes: 149 additions & 0 deletions backend/inventory/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1050,3 +1050,152 @@ def test_patch_item_box_updates_box_id(self):
assert response.status_code == status.HTTP_200_OK
self.item_b.refresh_from_db()
assert self.item_b.box_id == self.box_b.id


# ============================================================================
# TESTS FOR GET /api/inventory/export/ (CSV Export)
# ============================================================================


@pytest.mark.django_db
class TestExportItems:
"""Tests for the CSV export endpoint."""

EXPORT_URL = "/api/inventory/export/"

@pytest.fixture(autouse=True)
def setup(self, client, admin_user, volunteer_user, floor_location, storage_location):
self.client = client
self.admin_user = admin_user
self.volunteer_user = volunteer_user
self.floor_location = floor_location
self.storage_location = storage_location

# Create a box for filtering tests
self.box = Box.objects.create(box_code="BOX001", label="Export Test Box", location=floor_location)

# Create test items
self.item_software = CollectionItem.objects.create(
item_code="EXP001",
title="Export Test Game",
platform="SNES",
item_type="SOFTWARE",
current_location=floor_location,
box=self.box,
is_public_visible=True,
)
self.item_hardware = CollectionItem.objects.create(
item_code="EXP002",
title="Export Test Console",
platform="",
item_type="HARDWARE",
current_location=storage_location,
is_public_visible=True,
)

def _get_admin_token(self):
return get_admin_token(self.client)

def _get_volunteer_token(self):
return get_volunteer_token(self.client)

def test_unauthenticated_returns_401(self):
"""Unauthenticated request should be rejected."""
response = self.client.get(self.EXPORT_URL)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

def test_admin_gets_csv_response(self):
"""Admin should receive a CSV file response."""
token = self._get_admin_token()
response = self.client.get(self.EXPORT_URL, HTTP_AUTHORIZATION=f"Bearer {token}")
assert response.status_code == status.HTTP_200_OK
assert response["Content-Type"] == "text/csv"
assert "attachment" in response["Content-Disposition"]
assert "made_export_" in response["Content-Disposition"]

def test_volunteer_gets_csv_response(self):
"""Volunteer should also have access to export."""
token = self._get_volunteer_token()
response = self.client.get(self.EXPORT_URL, HTTP_AUTHORIZATION=f"Bearer {token}")
assert response.status_code == status.HTTP_200_OK
assert response["Content-Type"] == "text/csv"

def test_csv_contains_headers_and_data(self):
"""CSV should contain header row and data rows."""
token = self._get_admin_token()
response = self.client.get(self.EXPORT_URL, HTTP_AUTHORIZATION=f"Bearer {token}")
content = response.content.decode("utf-8")
lines = content.strip().split("\n")

# Header + at least 2 data rows
assert len(lines) >= 3
assert "MADE ID" in lines[0]
assert "Title" in lines[0]
assert "EXP001" in content
assert "EXP002" in content

def test_filter_by_record_type(self):
"""Filtering by record_type should return only matching items."""
token = self._get_admin_token()
response = self.client.get(
f"{self.EXPORT_URL}?record_type=SOFTWARE",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
content = response.content.decode("utf-8")
assert "EXP001" in content
assert "EXP002" not in content

def test_filter_by_box_id(self):
"""Filtering by box_id should return only items in that box."""
token = self._get_admin_token()
response = self.client.get(
f"{self.EXPORT_URL}?box_id={self.box.id}",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
content = response.content.decode("utf-8")
assert "EXP001" in content # In the box
assert "EXP002" not in content # Not in any box

def test_filter_by_date_range(self):
"""Filtering by start_date and end_date should scope results."""
token = self._get_admin_token()
today = timezone.now().strftime("%Y-%m-%d")
response = self.client.get(
f"{self.EXPORT_URL}?start_date={today}&end_date={today}",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
content = response.content.decode("utf-8")
# Items were created today, so they should appear
assert "EXP001" in content

def test_filter_future_date_returns_empty(self):
"""Filtering with a future start_date should return headers only."""
token = self._get_admin_token()
future = "2099-01-01"
response = self.client.get(
f"{self.EXPORT_URL}?start_date={future}",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
content = response.content.decode("utf-8")
lines = content.strip().split("\n")
# Only header row
assert len(lines) == 1
assert "MADE ID" in lines[0]

def test_invalid_start_date_returns_400(self):
"""Invalid date format should return 400."""
token = self._get_admin_token()
response = self.client.get(
f"{self.EXPORT_URL}?start_date=not-a-date",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST

def test_invalid_end_date_returns_400(self):
"""Invalid end_date format should return 400."""
token = self._get_admin_token()
response = self.client.get(
f"{self.EXPORT_URL}?end_date=bad",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
3 changes: 3 additions & 0 deletions backend/inventory/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
PublicCollectionItemViewSet,
AdminCollectionItemViewSet,
dashboard_stats,
export_items,
)

# from .views import InventoryItemViewSet
Expand Down Expand Up @@ -32,6 +33,7 @@
# PUT /api/inventory/items/{id}/ - Full update
# PATCH /api/inventory/items/{id}/ - Partial update
# DELETE /api/inventory/items/{id}/ - Soft delete (admin only)
# GET /api/inventory/export/ - Export items as CSV

router = DefaultRouter()
router.register(r"items", CollectionItemViewSet, basename="item")
Expand All @@ -43,4 +45,5 @@
path("", include(router.urls)),
path("public/", include(public_router.urls)),
path("stats/", dashboard_stats, name="dashboard-stats"),
path("export/", export_items, name="export-items"),
]
89 changes: 89 additions & 0 deletions backend/inventory/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import csv
from datetime import datetime

from django.http import HttpResponse
from rest_framework import viewsets, permissions, filters, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
Expand Down Expand Up @@ -163,3 +167,88 @@ def dashboard_stats(request):
"total_locations": Location.objects.count(),
}
)


@api_view(["GET"])
@permission_classes([IsVolunteer])
def export_items(request):
"""
Export collection items as a CSV file.
Accepts optional query parameters:
- start_date (YYYY-MM-DD): filter items created on or after this date
- end_date (YYYY-MM-DD): filter items created on or before this date
- box_id (int): filter items belonging to a specific box
- record_type (str): filter by item_type (SOFTWARE, HARDWARE, NON_ELECTRONIC)
"""
queryset = CollectionItem.objects.all().select_related("box", "current_location")

# Apply filters
start_date = request.query_params.get("start_date")
end_date = request.query_params.get("end_date")
box_id = request.query_params.get("box_id")
record_type = request.query_params.get("record_type")

if start_date:
try:
parsed = datetime.strptime(start_date, "%Y-%m-%d")
queryset = queryset.filter(created_at__date__gte=parsed.date())
except ValueError:
return Response(
{"error": "Invalid start_date format. Use YYYY-MM-DD."},
status=status.HTTP_400_BAD_REQUEST,
)
Comment on lines +196 to +199
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error responses here use an { "error": ... } payload, but other API endpoints in this codebase typically return { "detail": ... } for client-facing errors. For consistency (and to better align with DRF conventions), consider switching these to { "detail": ... } or raising a DRF ValidationError.

Copilot uses AI. Check for mistakes.

if end_date:
try:
parsed = datetime.strptime(end_date, "%Y-%m-%d")
queryset = queryset.filter(created_at__date__lte=parsed.date())
except ValueError:
Comment on lines +191 to +205
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The created_at__date__gte/lte filters apply a date extraction function to the DB column, which can prevent index usage and become slow on larger tables. Consider filtering with datetime boundaries instead (e.g., created_at__gte=start_of_day and created_at__lt=end_of_day_plus_1) to keep the query sargable.

Copilot uses AI. Check for mistakes.
return Response(
{"error": "Invalid end_date format. Use YYYY-MM-DD."},
status=status.HTTP_400_BAD_REQUEST,
)

if box_id:
queryset = queryset.filter(box__id=box_id)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

box_id is used directly in the ORM filter; if a non-numeric value is provided (e.g. box_id=abc) Django will raise ValueError: Field 'id' expected a number and return a 500. Parse/validate box_id (and return a 400 on invalid input) before applying the filter, similar to the date parsing above.

Suggested change
queryset = queryset.filter(box__id=box_id)
try:
box_id_int = int(box_id)
except (TypeError, ValueError):
return Response(
{"error": "Invalid box_id. Must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
queryset = queryset.filter(box__id=box_id_int)

Copilot uses AI. Check for mistakes.

if record_type:
queryset = queryset.filter(item_type=record_type)

# Build CSV response
today = datetime.now().strftime("%Y%m%d")
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="made_export_{today}.csv"'
Comment on lines +218 to +220
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

today = datetime.now() produces a naive timestamp and may not match the app's configured timezone. Use Django's timezone utilities (e.g. django.utils.timezone.localdate()/timezone.now()) so the exported filename date is consistent with the rest of the system.

Copilot uses AI. Check for mistakes.

writer = csv.writer(response)
writer.writerow(
[
"MADE ID",
"Title",
"Platform",
"Item Type",
"Box Code",
"Location",
"Location Type",
"Working Condition",
"Status",
"Created At",
]
)

for item in queryset:
writer.writerow(
Comment on lines +217 to +239
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint can return a very large CSV (especially with no filters) and currently builds it into a normal HttpResponse. For better reliability and memory usage, consider switching to StreamingHttpResponse and iterating the queryset (e.g. queryset.iterator()) so exports don't require buffering the entire file in memory.

Copilot uses AI. Check for mistakes.
[
item.item_code,
item.title,
item.platform,
item.get_item_type_display(),
item.box.box_code if item.box else "",
item.current_location.name if item.current_location else "",
item.current_location.get_location_type_display() if item.current_location else "",
"Yes" if item.working_condition else "No",
item.get_status_display(),
item.created_at.strftime("%Y-%m-%d %H:%M:%S") if item.created_at else "",
]
Comment on lines +240 to +251
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSV export is vulnerable to spreadsheet/CSV injection if any user-controlled string fields start with =, +, -, or @ (e.g. title, platform, location name). Consider sanitizing these values before writing (commonly by prefixing an apostrophe) so opening the CSV in Excel/Sheets can’t execute formulas.

Copilot uses AI. Check for mistakes.
)

return response
Loading