Skip to content

Commit 02b9fb3

Browse files
committed
optimize export to csv
1 parent 9c509ea commit 02b9fb3

File tree

4 files changed

+65
-83
lines changed

4 files changed

+65
-83
lines changed

src/integrations/exports.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,56 @@
1010
logger = logging.getLogger(__name__)
1111

1212

13-
def db_to_csv(response, user):
14-
"""Export a CSV file of the user's media."""
13+
class Echo:
14+
"""An object that implements just the write method of the file-like interface."""
15+
16+
def write(self, value):
17+
"""Write the value by returning it, instead of storing in a buffer."""
18+
return value
19+
20+
21+
def generate_rows(user):
22+
"""Generate CSV rows."""
23+
pseudo_buffer = Echo()
24+
writer = csv.writer(pseudo_buffer, quoting=csv.QUOTE_ALL)
25+
26+
# Get fields
1527
fields = {
1628
"item": get_model_fields(Item),
1729
"track": get_track_fields(),
1830
}
1931

20-
writer = csv.writer(response, quoting=csv.QUOTE_ALL)
21-
writer.writerow(fields["item"] + fields["track"])
32+
# Yield header row
33+
yield writer.writerow(fields["item"] + fields["track"])
2234

35+
# Yield data rows
2336
media_types = Item.MediaTypes.values
2437
for media_type in media_types:
2538
model = apps.get_model("app", media_type)
2639
if media_type == "episode":
2740
queryset = model.objects.filter(related_season__user=user)
2841
else:
2942
queryset = model.objects.filter(user=user)
30-
write_model_to_csv(writer, fields, queryset, media_type)
3143

32-
return response
44+
logger.debug("Streaming %ss to CSV", media_type)
45+
46+
# Stream each row
47+
for media in queryset.iterator(): # Using iterator() for memory efficiency
48+
# Build row data
49+
row = [getattr(media.item, field, "") for field in fields["item"]] + [
50+
getattr(media, field, "") for field in fields["track"]
51+
]
52+
53+
if media_type == "game":
54+
# calculate index of progress field
55+
progress_index = fields["track"].index("progress")
56+
row[progress_index + len(fields["item"])] = helpers.minutes_to_hhmm(
57+
media.progress,
58+
)
59+
60+
yield writer.writerow(row)
61+
62+
logger.debug("Finished streaming %ss to CSV", media_type)
3363

3464

3565
def get_model_fields(model):
@@ -64,23 +94,3 @@ def get_track_fields():
6494
all_fields.insert(end_idx, "start_date")
6595

6696
return list(all_fields)
67-
68-
69-
def write_model_to_csv(writer, fields, queryset, media_type):
70-
"""Export entries from a model to a CSV file."""
71-
logger.info("Adding %ss to CSV", media_type)
72-
73-
for media in queryset:
74-
# row with item and track fields
75-
row = [getattr(media.item, field, "") for field in fields["item"]] + [
76-
getattr(media, field, "") for field in fields["track"]
77-
]
78-
79-
if media_type == "game":
80-
# calculate index of progress field
81-
progress_index = fields["track"].index("progress")
82-
row[progress_index + len(fields["item"])] = helpers.minutes_to_hhmm(
83-
media.progress,
84-
)
85-
86-
writer.writerow(row)

src/integrations/tests/test_exports.py

Lines changed: 17 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from io import StringIO
44

55
from django.contrib.auth import get_user_model
6+
from django.db.models import Q
67
from django.test import TestCase
78
from django.urls import reverse
89

@@ -156,52 +157,26 @@ def test_export_csv(self):
156157
# Assert that the response content type is text/csv
157158
self.assertEqual(response["Content-Type"], "text/csv")
158159

159-
# Read the CSV content from the response
160-
csv_content = response.content.decode("utf-8")
160+
# Read the streaming content and decode it
161+
content = b"".join(response.streaming_content).decode("utf-8")
161162

162163
# Create a CSV reader from the CSV content
163-
reader = csv.DictReader(StringIO(csv_content))
164+
reader = csv.DictReader(StringIO(content))
164165

165-
# Get all media IDs from the database
166166
db_media_ids = set(
167-
TV.objects.values_list("item__media_id", flat=True).filter(user=self.user),
168-
)
169-
db_media_ids.update(
170-
Movie.objects.values_list("item__media_id", flat=True).filter(
171-
user=self.user,
172-
),
173-
)
174-
db_media_ids.update(
175-
Season.objects.values_list("item__media_id", flat=True).filter(
176-
user=self.user,
177-
),
178-
)
179-
db_media_ids.update(
180-
Episode.objects.values_list("item__media_id", flat=True).filter(
181-
related_season__user=self.user,
182-
),
183-
)
184-
db_media_ids.update(
185-
Anime.objects.values_list("item__media_id", flat=True).filter(
186-
user=self.user,
187-
),
188-
)
189-
db_media_ids.update(
190-
Manga.objects.values_list("item__media_id", flat=True).filter(
191-
user=self.user,
192-
),
193-
)
194-
db_media_ids.update(
195-
Game.objects.values_list("item__media_id", flat=True).filter(
196-
user=self.user,
197-
),
198-
)
199-
db_media_ids.update(
200-
Book.objects.values_list("item__media_id", flat=True).filter(
201-
user=self.user,
202-
),
203-
)
204-
167+
Item.objects.filter(
168+
Q(tv__user=self.user)
169+
| Q(movie__user=self.user)
170+
| Q(season__user=self.user)
171+
| Q(episode__related_season__user=self.user)
172+
| Q(anime__user=self.user)
173+
| Q(manga__user=self.user)
174+
| Q(game__user=self.user)
175+
| Q(book__user=self.user),
176+
).values_list("media_id", flat=True),
177+
)
178+
179+
# Verify each row in the CSV exists in the database
205180
for row in reader:
206181
media_id = row["media_id"]
207182
self.assertIn(media_id, db_media_ids)

src/integrations/views.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.contrib import messages
88
from django.contrib.auth.decorators import login_not_required
99
from django.core.exceptions import ObjectDoesNotExist
10-
from django.http import HttpResponse
10+
from django.http import HttpResponse, StreamingHttpResponse
1111
from django.shortcuts import redirect
1212
from django.urls import reverse
1313
from django.utils import timezone
@@ -184,16 +184,12 @@ def import_yamtrack(request):
184184
def export_csv(request):
185185
"""View for exporting all media data to a CSV file."""
186186
today = timezone.now().strftime("%Y-%m-%d")
187-
# Create the HttpResponse object with the appropriate CSV header.
188-
response = HttpResponse(
187+
response = StreamingHttpResponse(
188+
streaming_content=exports.generate_rows(request.user),
189189
content_type="text/csv",
190190
headers={"Content-Disposition": f'attachment; filename="yamtrack_{today}.csv"'},
191191
)
192-
193-
response = exports.db_to_csv(response, request.user)
194-
195-
logger.info("User %s successfully exported their data", request.user.username)
196-
192+
logger.info("User %s started CSV export", request.user.username)
197193
return response
198194

199195

src/templates/users/profile.html

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -443,8 +443,13 @@ <h5 class="card-title mb-1">YamTrack CSV</h5>
443443
<div class="card-body d-flex flex-column justify-content-between">
444444
<div class="d-flex flex-column flex-lg-row flex-wrap justify-content-between align-items-lg-center row-gap-4">
445445
<div class="d-flex gap-3 flex-wrap">
446-
<svg xmlns="http://www.w3.org/2000/svg" width="58" height="58" fill="currentColor" class="bi bi-filetype-csv" viewBox="0 0 16 16">
447-
<path fill-rule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM3.517 14.841a1.13 1.13 0 0 0 .401.823q.195.162.478.252.284.091.665.091.507 0 .859-.158.354-.158.539-.44.187-.284.187-.656 0-.336-.134-.56a1 1 0 0 0-.375-.357 2 2 0 0 0-.566-.21l-.621-.144a1 1 0 0 1-.404-.176.37.37 0 0 1-.144-.299q0-.234.185-.384.188-.152.512-.152.214 0 .37.068a.6.6 0 0 1 .246.181.56.56 0 0 1 .12.258h.75a1.1 1.1 0 0 0-.2-.566 1.2 1.2 0 0 0-.5-.41 1.8 1.8 0 0 0-.78-.152q-.439 0-.776.15-.337.149-.527.421-.19.273-.19.639 0 .302.122.524.124.223.352.367.228.143.539.213l.618.144q.31.073.463.193a.39.39 0 0 1 .152.326.5.5 0 0 1-.085.29.56.56 0 0 1-.255.193q-.167.07-.413.07-.175 0-.32-.04a.8.8 0 0 1-.248-.115.58.58 0 0 1-.255-.384zM.806 13.693q0-.373.102-.633a.87.87 0 0 1 .302-.399.8.8 0 0 1 .475-.137q.225 0 .398.097a.7.7 0 0 1 .272.26.85.85 0 0 1 .12.381h.765v-.072a1.33 1.33 0 0 0-.466-.964 1.4 1.4 0 0 0-.489-.272 1.8 1.8 0 0 0-.606-.097q-.534 0-.911.223-.375.222-.572.632-.195.41-.196.979v.498q0 .568.193.976.197.407.572.626.375.217.914.217.439 0 .785-.164t.55-.454a1.27 1.27 0 0 0 .226-.674v-.076h-.764a.8.8 0 0 1-.118.363.7.7 0 0 1-.272.25.9.9 0 0 1-.401.087.85.85 0 0 1-.478-.132.83.83 0 0 1-.299-.392 1.7 1.7 0 0 1-.102-.627zm8.239 2.238h-.953l-1.338-3.999h.917l.896 3.138h.038l.888-3.138h.879z"/>
446+
<svg xmlns="http://www.w3.org/2000/svg"
447+
width="58"
448+
height="58"
449+
fill="currentColor"
450+
class="bi bi-filetype-csv"
451+
viewBox="0 0 16 16">
452+
<path fill-rule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM3.517 14.841a1.13 1.13 0 0 0 .401.823q.195.162.478.252.284.091.665.091.507 0 .859-.158.354-.158.539-.44.187-.284.187-.656 0-.336-.134-.56a1 1 0 0 0-.375-.357 2 2 0 0 0-.566-.21l-.621-.144a1 1 0 0 1-.404-.176.37.37 0 0 1-.144-.299q0-.234.185-.384.188-.152.512-.152.214 0 .37.068a.6.6 0 0 1 .246.181.56.56 0 0 1 .12.258h.75a1.1 1.1 0 0 0-.2-.566 1.2 1.2 0 0 0-.5-.41 1.8 1.8 0 0 0-.78-.152q-.439 0-.776.15-.337.149-.527.421-.19.273-.19.639 0 .302.122.524.124.223.352.367.228.143.539.213l.618.144q.31.073.463.193a.39.39 0 0 1 .152.326.5.5 0 0 1-.085.29.56.56 0 0 1-.255.193q-.167.07-.413.07-.175 0-.32-.04a.8.8 0 0 1-.248-.115.58.58 0 0 1-.255-.384zM.806 13.693q0-.373.102-.633a.87.87 0 0 1 .302-.399.8.8 0 0 1 .475-.137q.225 0 .398.097a.7.7 0 0 1 .272.26.85.85 0 0 1 .12.381h.765v-.072a1.33 1.33 0 0 0-.466-.964 1.4 1.4 0 0 0-.489-.272 1.8 1.8 0 0 0-.606-.097q-.534 0-.911.223-.375.222-.572.632-.195.41-.196.979v.498q0 .568.193.976.197.407.572.626.375.217.914.217.439 0 .785-.164t.55-.454a1.27 1.27 0 0 0 .226-.674v-.076h-.764a.8.8 0 0 1-.118.363.7.7 0 0 1-.272.25.9.9 0 0 1-.401.087.85.85 0 0 1-.478-.132.83.83 0 0 1-.299-.392 1.7 1.7 0 0 1-.102-.627zm8.239 2.238h-.953l-1.338-3.999h.917l.896 3.138h.038l.888-3.138h.879z" />
448453
</svg>
449454
<div>
450455
<h5 class="card-title mb-1">Export Data</h5>
@@ -458,8 +463,6 @@ <h5 class="card-title mb-1">Export Data</h5>
458463
</button>
459464
</form>
460465
</div>
461-
462-
{% include "users/components/import_status.html" with import_task=import_tasks.kitsu source="kitsu" %}
463466
</div>
464467
</div>
465468
</div>
@@ -468,9 +471,7 @@ <h5 class="card-title mb-1">Export Data</h5>
468471
<legend class="border-bottom mb-4">Other</legend>
469472

470473
<div class="input-group profile-grid">
471-
<form class="p-2 grid-item"
472-
method="post"
473-
action="{% url 'reload_calendar' %}">
474+
<form class="p-2" method="post" action="{% url 'reload_calendar' %}">
474475
{% csrf_token %}
475476
<button class="btn btn-secondary w-100" type="submit">Reload Calendar</button>
476477
</form>

0 commit comments

Comments
 (0)