Skip to content

Commit 4e4599d

Browse files
authored
Merge pull request learningequality#5969 from bjester/user-csv-perf
Optimize query for user CSV export
2 parents a0c7298 + eec65c0 commit 4e4599d

2 files changed

Lines changed: 179 additions & 33 deletions

File tree

contentcuration/contentcuration/tests/test_user.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
from .base import StudioTestCase
1717
from .testdata import fileobj_video
1818
from contentcuration.models import DEFAULT_CONTENT_DEFAULTS
19+
from contentcuration.models import File
1920
from contentcuration.models import Invitation
21+
from contentcuration.models import Language
2022
from contentcuration.models import User
2123
from contentcuration.models import UserSubscription
2224
from contentcuration.tests import testdata
@@ -163,6 +165,80 @@ def test_user_csv_export(self):
163165
self.assertIn(_format_size(videos[index - 1].file_size), row)
164166
self.assertEqual(index, len(videos))
165167

168+
def test_user_csv_export_reports_channel_and_content_metadata(self):
169+
language = Language.objects.create(lang_code="fr", readable_name="French")
170+
file_record = File.objects.filter(
171+
contentnode__tree_id=self.channel.main_tree.tree_id
172+
).first()
173+
file_record.uploaded_by = self.user
174+
file_record.original_filename = "sample-video.mp4"
175+
file_record.language = None
176+
file_record.save()
177+
178+
contentnode = file_record.contentnode
179+
contentnode.title = "CSV Content Title"
180+
contentnode.description = "CSV Description"
181+
contentnode.author = "CSV Author"
182+
contentnode.language = language
183+
contentnode.license_description = "CSV License Description"
184+
contentnode.copyright_holder = "CSV Copyright Holder"
185+
contentnode.save()
186+
187+
with tempfile.NamedTemporaryFile(suffix=".csv") as tempf:
188+
write_user_csv(self.user, path=tempf.name)
189+
190+
with io.open(tempf.name, "r", encoding="utf-8") as csv_file:
191+
rows = list(csv.DictReader(csv_file, delimiter=","))
192+
193+
self.assertTrue(rows)
194+
row = rows[0]
195+
self.assertEqual(row["Channel"], self.channel.name)
196+
self.assertEqual(row["Title"], "CSV Content Title")
197+
self.assertEqual(row["Filename"], "sample-video.mp4")
198+
self.assertEqual(row["Description"], "CSV Description")
199+
self.assertEqual(row["Author"], "CSV Author")
200+
self.assertEqual(row["Language"], "French")
201+
self.assertEqual(row["License Description"], "CSV License Description")
202+
self.assertEqual(row["Copyright Holder"], "CSV Copyright Holder")
203+
204+
def test_user_csv_export_reports_staged_files(self):
205+
self.user.staged_files.create(checksum="stagedchecksum", file_size=2048)
206+
207+
with tempfile.NamedTemporaryFile(suffix=".csv") as tempf:
208+
write_user_csv(self.user, path=tempf.name)
209+
210+
with io.open(tempf.name, "r", encoding="utf-8") as csv_file:
211+
rows = list(csv.DictReader(csv_file, delimiter=","))
212+
213+
staged_rows = [row for row in rows if row["Filename"] == "Staged File"]
214+
self.assertEqual(len(staged_rows), 1)
215+
staged_row = staged_rows[0]
216+
self.assertEqual(staged_row["Channel"], "No Channel")
217+
self.assertEqual(staged_row["Title"], "No Resource")
218+
self.assertEqual(staged_row["File Size"], _format_size(2048))
219+
self.assertEqual(staged_row["URL"], "")
220+
221+
def test_user_csv_export_includes_files_without_contentnode(self):
222+
file_without_contentnode = fileobj_video()
223+
self.assertIsNone(file_without_contentnode.contentnode_id)
224+
file_without_contentnode.uploaded_by = self.user
225+
file_without_contentnode.original_filename = "no-contentnode.mp4"
226+
file_without_contentnode.save()
227+
228+
with tempfile.NamedTemporaryFile(suffix=".csv") as tempf:
229+
write_user_csv(self.user, path=tempf.name)
230+
231+
with io.open(tempf.name, "r", encoding="utf-8") as csv_file:
232+
rows = list(csv.DictReader(csv_file, delimiter=","))
233+
234+
row = next(
235+
row
236+
for row in rows
237+
if row["Filename"] == file_without_contentnode.original_filename
238+
)
239+
self.assertEqual(row["Title"], "No resource")
240+
self.assertEqual(row["Channel"], "No Channel")
241+
166242

167243
class UserEffectiveDiskSpaceTest(StudioTestCase):
168244
def setUp(self):

contentcuration/contentcuration/utils/csv_writer.py

Lines changed: 103 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66

77
from django.conf import settings
88
from django.contrib.sites.models import Site
9+
from django.db.models import Exists
10+
from django.db.models import F
911
from django.db.models import OuterRef
10-
from django.db.models import Q
1112
from django.db.models import Subquery
13+
from django.db.models.sql.constants import LOUTER
1214
from django.utils.translation import gettext as _
1315
from le_utils.constants import content_kinds
1416

17+
from contentcuration.db.models.query import With
1518
from contentcuration.models import Channel
19+
from contentcuration.models import ContentNode
1620
from contentcuration.models import generate_storage_url
1721

1822
if not os.path.exists(settings.CSV_ROOT):
@@ -43,29 +47,24 @@ def generate_user_csv_filename(user):
4347

4448

4549
def _write_user_row(file, writer, domain):
46-
filename = "{}.{}".format(file["checksum"], file["file_format__extension"])
50+
filename = "{}.{}".format(file["checksum"], file["file_extension"])
4751
writer.writerow(
4852
[
4953
file["channel_name"] or _("No Channel"),
50-
file["contentnode__title"] or _("No resource"),
54+
file["node_title"] or _("No resource"),
5155
next(
52-
(
53-
k[1]
54-
for k in content_kinds.choices
55-
if k[0] == file["contentnode__kind_id"]
56-
),
56+
(k[1] for k in content_kinds.choices if k[0] == file["node_kind_id"]),
5757
"",
5858
),
5959
file["original_filename"],
6060
_format_size(file["file_size"] or 0),
6161
generate_storage_url(filename),
62-
file["contentnode__description"],
63-
file["contentnode__author"],
64-
file["language__readable_name"]
65-
or file["contentnode__language__readable_name"],
66-
file["contentnode__license__license_name"],
67-
file["contentnode__license_description"],
68-
file["contentnode__copyright_holder"],
62+
file["node_description"],
63+
file["node_author"],
64+
file["file_language"] or file["node_language"],
65+
file["node_license_name"],
66+
file["node_license_description"],
67+
file["node_copyright_holder"],
6968
]
7069
)
7170

@@ -100,34 +99,105 @@ def write_user_csv(user, path=None):
10099

101100
domain = Site.objects.get(pk=1).domain
102101

103-
# Get all user files
104-
channel_query = Channel.objects.filter(
105-
Q(main_tree__tree_id=OuterRef("contentnode__tree_id"))
106-
| Q(trash_tree__tree_id=OuterRef("contentnode__tree_id"))
102+
# Build CTEs so we first reduce to this user's files, then resolve only
103+
# needed content node and channel fields.
104+
user_files_cte = With(
105+
user.files.values(
106+
"id",
107+
"contentnode_id",
108+
"original_filename",
109+
"file_size",
110+
"checksum",
111+
file_extension=F("file_format__extension"),
112+
file_language=F("language__readable_name"),
113+
),
114+
name="user_files",
115+
)
116+
117+
content_nodes_cte = With(
118+
user_files_cte.join(
119+
ContentNode.objects.all(),
120+
id=user_files_cte.col.contentnode_id,
121+
)
122+
.values(
123+
"id",
124+
"tree_id",
125+
node_title=F("title"),
126+
node_kind_id=F("kind_id"),
127+
node_description=F("description"),
128+
node_author=F("author"),
129+
node_language=F("language__readable_name"),
130+
node_license_name=F("license__license_name"),
131+
node_license_description=F("license_description"),
132+
node_copyright_holder=F("copyright_holder"),
133+
)
134+
.distinct(),
135+
name="content_nodes",
136+
)
137+
138+
main_channel_names = Channel.objects.filter(
139+
Exists(
140+
content_nodes_cte.queryset().filter(
141+
tree_id=OuterRef("main_tree__tree_id")
142+
)
143+
)
144+
).values(
145+
tree_id=F("main_tree__tree_id"),
146+
channel_name=F("name"),
147+
)
148+
trash_channel_names = Channel.objects.filter(
149+
Exists(
150+
content_nodes_cte.queryset().filter(
151+
tree_id=OuterRef("trash_tree__tree_id")
152+
)
153+
)
154+
).values(
155+
tree_id=F("trash_tree__tree_id"),
156+
channel_name=F("name"),
157+
)
158+
channel_names_cte = With(
159+
main_channel_names.union(trash_channel_names), name="channel_names"
107160
)
108161

109162
user_files = (
110-
user.files.select_related("language", "contentnode", "file_format")
163+
content_nodes_cte.join(
164+
user_files_cte.queryset(),
165+
contentnode_id=content_nodes_cte.col.id,
166+
_join_type=LOUTER,
167+
)
168+
.with_cte(user_files_cte)
169+
.with_cte(content_nodes_cte)
170+
.with_cte(channel_names_cte)
111171
.annotate(
112-
channel_name=Subquery(channel_query.values_list("name", flat=True)[:1])
172+
channel_name=Subquery(
173+
channel_names_cte.queryset()
174+
.filter(tree_id=content_nodes_cte.col.tree_id)
175+
.values("channel_name")[:1]
176+
),
177+
node_title=content_nodes_cte.col.node_title,
178+
node_kind_id=content_nodes_cte.col.node_kind_id,
179+
node_description=content_nodes_cte.col.node_description,
180+
node_author=content_nodes_cte.col.node_author,
181+
node_language=content_nodes_cte.col.node_language,
182+
node_license_name=content_nodes_cte.col.node_license_name,
183+
node_license_description=content_nodes_cte.col.node_license_description,
184+
node_copyright_holder=content_nodes_cte.col.node_copyright_holder,
113185
)
114186
.values(
115187
"channel_name",
116188
"original_filename",
117189
"file_size",
118190
"checksum",
119-
"file_format__extension",
120-
"language__readable_name",
121-
"contentnode__title",
122-
"contentnode__language__readable_name",
123-
"contentnode__license__license_name",
124-
"contentnode__kind_id",
125-
"contentnode__description",
126-
"contentnode__author",
127-
"contentnode__provider",
128-
"contentnode__aggregator",
129-
"contentnode__license_description",
130-
"contentnode__copyright_holder",
191+
"file_extension",
192+
"file_language",
193+
"node_title",
194+
"node_kind_id",
195+
"node_description",
196+
"node_author",
197+
"node_language",
198+
"node_license_name",
199+
"node_license_description",
200+
"node_copyright_holder",
131201
)
132202
)
133203
for file in user_files:

0 commit comments

Comments
 (0)