-
Notifications
You must be signed in to change notification settings - Fork 1
feat(backend): Implement CSV exporting #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, 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: | ||
|
Comment on lines
+232
to
+246
|
||
| 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"' | ||
|
Comment on lines
+266
to
+268
|
||
|
|
||
| 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
+265
to
+287
|
||
| [ | ||
| 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
+288
to
+299
|
||
| ) | ||
|
|
||
| return response | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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 DRFValidationError.