diff --git a/backend/inventory/tests.py b/backend/inventory/tests.py index f3038c9..5a6f58d 100644 --- a/backend/inventory/tests.py +++ b/backend/inventory/tests.py @@ -1128,3 +1128,152 @@ def test_mark_box_arrived_rejects_invalid_location(self): assert response.status_code == status.HTTP_400_BAD_REQUEST data = json.loads(response.content) assert data.get("detail") == "Destination location not found." + + +# ============================================================================ +# 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 diff --git a/backend/inventory/urls.py b/backend/inventory/urls.py index aa33da7..3a79035 100644 --- a/backend/inventory/urls.py +++ b/backend/inventory/urls.py @@ -5,6 +5,7 @@ PublicCollectionItemViewSet, AdminCollectionItemViewSet, dashboard_stats, + export_items, ) # from .views import InventoryItemViewSet @@ -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") @@ -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"), ] diff --git a/backend/inventory/views.py b/backend/inventory/views.py index 2a5ee1e..c28168f 100644 --- a/backend/inventory/views.py +++ b/backend/inventory/views.py @@ -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, action from rest_framework.response import Response @@ -204,3 +208,95 @@ 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, + ) + + if end_date: + try: + parsed = datetime.strptime(end_date, "%Y-%m-%d") + queryset = queryset.filter(created_at__date__lte=parsed.date()) + except ValueError: + return Response( + {"error": "Invalid end_date format. Use YYYY-MM-DD."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if 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) + + 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"' + + 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( + [ + 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 "", + ] + ) + + return response diff --git a/frontend/src/components/items/ExportModal.css b/frontend/src/components/items/ExportModal.css new file mode 100644 index 0000000..41000cf --- /dev/null +++ b/frontend/src/components/items/ExportModal.css @@ -0,0 +1,112 @@ +.export-modal { + background: var(--color-background); + border-radius: var(--radius-lg); + width: 100%; + max-width: 480px; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +} + +.export-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.export-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 28px; + color: var(--color-primary); +} + +.export-modal-body { + padding: var(--spacing-md) var(--spacing-lg); +} + +.export-modal-body .form-group { + margin-bottom: var(--spacing-sm); +} + +.export-modal-body .form-group label { + display: block; + margin-bottom: 4px; + font-size: 13px; + font-weight: 500; + line-height: 18px; + color: var(--color-primary); +} + +.export-modal-body .form-group input, +.export-modal-body .form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: 14px; + line-height: 20px; + font-family: inherit; + background: var(--color-background); + transition: border-color 0.2s; + box-sizing: border-box; +} + +.export-modal-body .form-group input:focus, +.export-modal-body .form-group select:focus { + outline: none; + border-color: var(--color-primary); +} + +.export-date-row { + display: flex; + gap: var(--spacing-sm); +} + +.export-date-row .form-group { + flex: 1; + min-width: 0; +} + +.export-modal-footer { + display: flex; + gap: var(--spacing-sm); + justify-content: flex-end; + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--color-border); + background: var(--color-background-gray); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + +.export-modal-description { + margin: 0 0 var(--spacing-md) 0; + font-size: 13px; + line-height: 20px; + color: var(--color-secondary); +} + +.export-error { + margin-bottom: var(--spacing-md); + padding: var(--spacing-sm); + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: var(--radius-md); + color: var(--color-error); + font-size: 13px; + line-height: 20px; +} + +@media (max-width: 600px) { + .export-date-row { + flex-direction: column; + gap: 0; + } + + .export-modal { + max-width: 100%; + margin: var(--spacing-md); + } +} diff --git a/frontend/src/components/items/ExportModal.tsx b/frontend/src/components/items/ExportModal.tsx new file mode 100644 index 0000000..35cb4e6 --- /dev/null +++ b/frontend/src/components/items/ExportModal.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect } from 'react'; +import { boxesApi } from '../../api/boxes.api'; +import type { Box } from '../../api/boxes.api'; +import apiClient from '../../api/apiClient'; +import './ExportModal.css'; + +interface ExportModalProps { + isOpen: boolean; + onClose: () => void; +} + +const ExportModal: React.FC = ({ isOpen, onClose }) => { + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [boxId, setBoxId] = useState(''); + const [recordType, setRecordType] = useState(''); + const [boxes, setBoxes] = useState([]); + const [isExporting, setIsExporting] = useState(false); + const [error, setError] = useState(null); + + // Fetch boxes for the dropdown + useEffect(() => { + if (isOpen) { + boxesApi.getAll() + .then(setBoxes) + .catch(() => setBoxes([])); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleExport = async () => { + setError(null); + setIsExporting(true); + + try { + const params: Record = {}; + if (startDate) params.start_date = startDate; + if (endDate) params.end_date = endDate; + if (boxId) params.box_id = boxId; + if (recordType) params.record_type = recordType; + + const response = await apiClient.get('/inventory/export/', { + params, + responseType: 'blob', + }); + + // Extract filename from Content-Disposition header or use default + const disposition = response.headers['content-disposition'] || ''; + const filenameMatch = disposition.match(/filename="?(.+?)"?$/); + const filename = filenameMatch ? filenameMatch[1] : 'made_export.csv'; + + // Download the file + const blob = new Blob([response.data], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + handleClose(); + } catch (err) { + console.error('Export failed:', err); + setError('Failed to export data. Please try again.'); + } finally { + setIsExporting(false); + } + }; + + const handleClose = () => { + setStartDate(''); + setEndDate(''); + setBoxId(''); + setRecordType(''); + setError(null); + onClose(); + }; + + return ( +
+
e.stopPropagation()}> +
+

Export Collection Data

+ +
+ +
+

+ Select filters to narrow your export, or leave all fields empty to export the entire collection. +

+ + {error &&
{error}
} + + {/* Date Range */} +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+ + {/* Box Filter */} +
+ + +
+ + {/* Record Type Filter */} +
+ + +
+
+ +
+ + +
+
+
+ ); +}; + +export default ExportModal; diff --git a/frontend/src/components/items/index.ts b/frontend/src/components/items/index.ts index b5b113d..c846747 100644 --- a/frontend/src/components/items/index.ts +++ b/frontend/src/components/items/index.ts @@ -5,4 +5,5 @@ export { default as ItemList } from './ItemList' export { default as VolunteerList } from '../volunteers/VolunteerList'; export { default as AddItemModal } from './AddItemModal'; export { default as EditItemModal } from './EditItemModal'; -export { default as DeleteItemDialog } from './DeleteItemDialog'; \ No newline at end of file +export { default as DeleteItemDialog } from './DeleteItemDialog'; +export { default as ExportModal } from './ExportModal'; \ No newline at end of file diff --git a/frontend/src/pages/admin/AdminCataloguePage.test.tsx b/frontend/src/pages/admin/AdminCataloguePage.test.tsx index b99c1dc..7efa731 100644 --- a/frontend/src/pages/admin/AdminCataloguePage.test.tsx +++ b/frontend/src/pages/admin/AdminCataloguePage.test.tsx @@ -15,6 +15,8 @@ vi.mock("../../components/items", () => ({ EditItemModal: ({ isOpen }: { isOpen: boolean }) => isOpen ?
: null, DeleteItemDialog: () => null, + ExportModal: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null, })) beforeEach(() => vi.clearAllMocks()) diff --git a/frontend/src/pages/admin/AdminCataloguePage.tsx b/frontend/src/pages/admin/AdminCataloguePage.tsx index d0158ef..55820a4 100644 --- a/frontend/src/pages/admin/AdminCataloguePage.tsx +++ b/frontend/src/pages/admin/AdminCataloguePage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { AddItemModal, EditItemModal, DeleteItemDialog } from '../../components/items'; +import { AddItemModal, EditItemModal, DeleteItemDialog, ExportModal } from '../../components/items'; import { itemsApi } from '../../api/items.api'; import type { AdminCollectionItem, ItemType, ItemStatus } from '../../lib/types'; import { Link } from 'react-router-dom'; @@ -144,6 +144,7 @@ const AdminCataloguePage: React.FC = () => { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); const [selectedItem, setSelectedItem] = useState(null); const [showFilters, setShowFilters] = useState(false); @@ -218,33 +219,6 @@ const AdminCataloguePage: React.FC = () => { return true; }); - // Export CSV handler - const handleExportCSV = () => { - const headers = ['Game Title', 'MADE ID', 'System', 'Type', 'Box ID', 'Location', 'Working Condition', 'Status']; - const csvRows = [headers.join(',')]; - inventoryItems.forEach(({ display }) => { - const row = [ - `"${display.title}"`, - display.item_code, - display.platform, - getTypeLabel(display.item_type), - display.box_code, - getLocationLabel(display.location_type, display.location_name), - display.working_condition ? 'Yes' : 'No', - getStatusLabel(display.status), - ]; - csvRows.push(row.join(',')); - }); - const csvContent = csvRows.join('\n'); - const blob = new Blob([csvContent], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'collection_catalogue.csv'; - a.click(); - URL.revokeObjectURL(url); - }; - return (
{/* Header */} @@ -332,7 +306,7 @@ const AdminCataloguePage: React.FC = () => {
-
@@ -563,6 +537,11 @@ const AdminCataloguePage: React.FC = () => { onConfirm={handleDeleteConfirm} itemTitle={selectedItem?.title || ''} /> + + setIsExportModalOpen(false)} + />
); }; diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index cc44e06..b134d91 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -5,6 +5,7 @@ import { usePendingRequests } from '../../actions/useRequests'; import { useDashboardStats } from '../../actions/useStats'; import type { MovementRequest } from '../../lib/types'; import Button from '../../components/common/Button'; +import { ExportModal } from '../../components/items'; import './AdminDashboard.css'; function formatTimeAgo(dateString: string): string { @@ -25,6 +26,7 @@ const AdminDashboard: React.FC = () => { const { requests: pendingRequests, loading, approve, reject } = usePendingRequests(); const { stats, loading: statsLoading } = useDashboardStats(); const [processingId, setProcessingId] = useState(null); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); const handleApprove = async (request: MovementRequest) => { setProcessingId(request.id); @@ -148,10 +150,16 @@ const AdminDashboard: React.FC = () => { - + + {/* Export Modal */} + setIsExportModalOpen(false)} + /> ); };