-
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
base: main
Are you sure you want to change the base?
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 | ||||||||||||||||||||
| from rest_framework.response import Response | ||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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
|
||||||||||||||||||||
| 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) | ||||||||||||||||||||
|
||||||||||||||||||||
| 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
AI
Mar 9, 2026
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.
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
AI
Mar 9, 2026
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.
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
AI
Mar 9, 2026
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.
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.
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.