Skip to content

Commit b65df4b

Browse files
committed
[fix] Fixed hanlding of multiple sub-fields for OneToOne/ForeignKey
1 parent d429280 commit b65df4b

2 files changed

Lines changed: 44 additions & 20 deletions

File tree

openwisp_users/management/commands/export_users.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,20 @@ def handle(self, *args, **options):
8989
)
9090
)
9191

92-
def serialize_related(self, manager, subfields):
93-
"""Serialize a RelatedManager queryset using the given subfields.
92+
def _format_rows(self, rows):
93+
"""Format rows into a cell string.
9494
9595
Single subfield → comma-separated values: val1,val2,...
9696
Multiple subfields → tuple-per-row format: ((v1,v2),(v3,v4))
9797
"""
98+
if not rows:
99+
return ""
100+
if len(rows[0]) == 1:
101+
return ",".join(row[0] for row in rows)
102+
return "\n".join("(" + ",".join(row) + ")" for row in rows)
103+
104+
def serialize_related(self, manager, subfields):
105+
"""Serialize a RelatedManager queryset using the given subfields."""
98106
rows = []
99107
# We use manager.all() instead of manager.iterator() to utilize the
100108
# prefetch_related queryset cache. The iterator() method would bypass the cache
@@ -106,11 +114,7 @@ def serialize_related(self, manager, subfields):
106114
val = self._get_nested_attr(obj, f)
107115
row.append(self._normalize_value(val))
108116
rows.append(row)
109-
if not rows:
110-
return ""
111-
if len(subfields) == 1:
112-
return ",".join(row[0] for row in rows)
113-
return "(" + ",".join("(" + ",".join(row) + ")" for row in rows) + ")"
117+
return self._format_rows(rows)
114118

115119
def _get_nested_attr(self, obj, attr_path):
116120
"""Resolve a dotted attribute path on an object.
@@ -170,9 +174,10 @@ def _get_field_value(self, user, field):
170174
return ""
171175
if isinstance(attr, (QuerySet, BaseManager)):
172176
return self.serialize_related(attr, subfields)
173-
return ",".join(
177+
row = [
174178
self._normalize_value(self._get_nested_attr(attr, f)) for f in subfields
175-
)
179+
]
180+
return self._format_rows([row])
176181
val = self._get_nested_attr(user, name)
177182
if isinstance(val, (QuerySet, BaseManager)):
178183
return ""

openwisp_users/tests/test_commands.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import csv
2+
import re
23
from io import StringIO
34
from unittest.mock import patch
45

@@ -226,8 +227,10 @@ def _broken_command_error_callable(user):
226227
{
227228
"fields": [
228229
"id",
229-
# single related object with subfields
230+
# single related object with single subfield
230231
{"name": "auth_token", "fields": ["key"]},
232+
# single related object with multiple subfields
233+
{"name": "auth_token", "fields": ["key", "created"]},
231234
# nullable field
232235
{"name": "birth_date", "fields": ["year"]},
233236
# manager with single subfield
@@ -264,18 +267,34 @@ def test_subfields_dict_field(self):
264267
self.assertEqual(len(csv_data), 3)
265268
# user1: token present, birth_date None, one org membership
266269
self.assertEqual(csv_data[1][0], str(user1.id))
267-
self.assertEqual(csv_data[1][1], str(token.key)) # subfields, single obj
268-
self.assertEqual(csv_data[1][2], "") # birth_date is None
269-
self.assertEqual(csv_data[1][3], str(org.id)) # single-subfield manager
270-
self.assertEqual(csv_data[1][4], f"(({org.id},True))") # multi-subfield manager
271-
self.assertEqual(csv_data[1][5], str(org.id)) # dot-notation manager
270+
# subfields, single obj
271+
self.assertEqual(csv_data[1][1], str(token.key))
272+
# single-obj multi-subfield: wrapped shape ((key,created))
273+
self.assertRegex(
274+
csv_data[1][2],
275+
rf"^\({re.escape(token.key)},",
276+
)
277+
# birth_date is None
278+
self.assertEqual(csv_data[1][3], "")
279+
# single-subfield manager
280+
self.assertEqual(csv_data[1][4], str(org.id))
281+
# multi-subfield manager
282+
self.assertEqual(csv_data[1][5], f"({org.id},True)")
283+
# dot-notation manager
284+
self.assertEqual(csv_data[1][6], str(org.id))
272285
# user2: no token, no org membership
273286
self.assertEqual(csv_data[2][0], str(user2.id))
274-
self.assertEqual(csv_data[2][1], "") # ObjectDoesNotExist auth_token
275-
self.assertEqual(csv_data[2][2], "") # birth_date is None
276-
self.assertEqual(csv_data[2][3], "") # empty manager
277-
self.assertEqual(csv_data[2][4], "") # empty manager
278-
self.assertEqual(csv_data[2][5], "") # empty dot-notation manager
287+
# ObjectDoesNotExist auth_token
288+
self.assertEqual(csv_data[2][1], "")
289+
# ObjectDoesNotExist auth_token multi-subfield
290+
self.assertEqual(csv_data[2][2], "")
291+
# birth_date is None
292+
self.assertEqual(csv_data[2][3], "")
293+
# empty manager
294+
self.assertEqual(csv_data[2][4], "")
295+
self.assertEqual(csv_data[2][5], "")
296+
# empty dot-notation manager
297+
self.assertEqual(csv_data[2][6], "")
279298

280299
def test_dot_notation_objectdoesnotexist_on_sub_attr(self):
281300
"""Returns empty string when sub-attribute access raises ObjectDoesNotExist."""

0 commit comments

Comments
 (0)