Skip to content

Commit b9f3cc9

Browse files
committed
[users] Add extensible field serialization to export_users command #497
Refactored export_users to support extensible field definitions. Fields can now be defined as dictionaries to enable: - custom serialization via callables - extraction of related objects using "fields" - traversal of relations via dot notation Replaced hardcoded "organizations" handling with a callable-based implementation. Added support for "prefetch_related" to optimize queries and avoid N+1 issues. Updated tests and documentation to reflect the new configuration format. Closes #497
1 parent 2ef104e commit b9f3cc9

4 files changed

Lines changed: 260 additions & 56 deletions

File tree

docs/user/settings.rst

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ without having to specify the international prefix.
8181
``OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG``
8282
----------------------------------------------
8383

84-
============ =============================
84+
============ ==========================================================================
8585
**type**: ``dict``
8686
**default**: .. code-block:: python
8787

@@ -101,17 +101,101 @@ without having to specify the international prefix.
101101
"location",
102102
"notes",
103103
"language",
104-
"organizations",
104+
{
105+
"name": "organizations",
106+
"callable": openwisp_users.settings._export_organizations,
107+
},
105108
],
106109
"select_related": [],
110+
"prefetch_related": [],
107111
}
108-
============ =============================
112+
============ ==========================================================================
109113

110-
This setting can be used to configure the exported fields for the
111-
:ref:`export_users` command.
114+
This setting configures the fields exported by the :ref:`export_users`
115+
management command.
112116

113-
The ``select_related`` property can be used to optimize the database
114-
query.
117+
Field definitions
118+
~~~~~~~~~~~~~~~~~
119+
120+
Each entry in ``fields`` can be either:
121+
122+
- A string, representing a direct attribute of the user model:
123+
124+
.. code-block:: python
125+
126+
"email"
127+
128+
- A dictionary, allowing advanced customization:
129+
130+
.. code-block:: python
131+
132+
{
133+
"name": "organizations",
134+
"callable": my_custom_function,
135+
}
136+
137+
The following keys are supported in field dictionaries:
138+
139+
- ``name`` (str): the field name used as the CSV column header.
140+
- ``callable`` (callable, optional): a function that takes the user
141+
instance as input and returns the value to be exported.
142+
- ``fields`` (list of str, optional): a list of attributes to extract from
143+
a related object or queryset.
144+
145+
Priority order:
146+
147+
- If ``callable`` is provided, it is used.
148+
- Else if ``fields`` is provided, the related object(s) are serialized.
149+
- Otherwise, the value is resolved using ``name``.
150+
151+
Related objects
152+
~~~~~~~~~~~~~~~
153+
154+
You can export fields from related models in two ways:
155+
156+
- **Dot notation** (for ``ForeignKey`` or ``OneToOne``):
157+
158+
.. code-block:: python
159+
160+
"profile.phone_number"
161+
162+
- **Structured extraction using ``fields``**:
163+
164+
.. code-block:: python
165+
166+
{
167+
"name": "groups",
168+
"fields": ["name"],
169+
}
170+
171+
If the attribute resolves to a queryset (e.g. reverse ``ForeignKey`` or
172+
``ManyToMany``), multiple values are serialized into a single CSV cell.
173+
174+
Query optimization
175+
~~~~~~~~~~~~~~~~~~
176+
177+
- ``select_related`` can be used for ``ForeignKey`` and ``OneToOne``
178+
relations.
179+
- ``prefetch_related`` can be used for reverse relations and
180+
``ManyToMany`` fields.
181+
182+
Example:
183+
184+
.. code-block:: python
185+
186+
{
187+
"fields": [
188+
"username",
189+
"email",
190+
"profile.phone_number",
191+
{
192+
"name": "groups",
193+
"fields": ["name"],
194+
},
195+
],
196+
"select_related": ["profile"],
197+
"prefetch_related": ["groups"],
198+
}
115199
116200
.. _openwisp_users_user_password_expiration:
117201

openwisp_users/management/commands/export_users.py

Lines changed: 92 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@
99
User = get_user_model()
1010

1111

12+
def normalize_field(field):
13+
"""Normalize a string or dict field definition to a dict."""
14+
if isinstance(field, dict):
15+
return field
16+
return {"name": field}
17+
18+
19+
def resolve_attr(obj, attr_name):
20+
"""Return the attribute value on obj, or None if it does not exist."""
21+
try:
22+
return getattr(obj, attr_name)
23+
except ObjectDoesNotExist:
24+
return None
25+
26+
27+
def serialize_related(manager, subfields):
28+
"""Serialize a RelatedManager queryset using the given subfields.
29+
30+
Single subfield → comma-separated values: val1,val2,...
31+
Multiple subfields → tuple-per-row format: ((v1,v2),(v3,v4))
32+
"""
33+
rows = [[str(getattr(obj, f, "")) for f in subfields] for obj in manager.iterator()]
34+
if not rows:
35+
return ""
36+
if len(subfields) == 1:
37+
return ",".join(row[0] for row in rows)
38+
return "(" + ",".join("(" + ",".join(row) + ")" for row in rows) + ")"
39+
40+
1241
class Command(BaseCommand):
1342
help = "Exports user data to a CSV file"
1443

@@ -33,47 +62,72 @@ def handle(self, *args, **options):
3362
fields = app_settings.EXPORT_USERS_COMMAND_CONFIG.get("fields", []).copy()
3463
# Get the fields to be excluded from the command-line argument
3564
exclude_fields = options.get("exclude_fields").split(",")
36-
# Remove excluded fields from the export fields
37-
fields = [field for field in fields if field not in exclude_fields]
38-
# Fetch all user data in a single query using select_related for related models
39-
queryset = User.objects.select_related(
40-
*app_settings.EXPORT_USERS_COMMAND_CONFIG.get("select_related", []),
41-
).order_by("date_joined")
65+
# Remove excluded fields from the export fields (match on the field name)
66+
fields = [
67+
field
68+
for field in fields
69+
if normalize_field(field)["name"] not in exclude_fields
70+
]
71+
# Fetch all user data using select_related and prefetch_related
72+
queryset = (
73+
User.objects.select_related(
74+
*app_settings.EXPORT_USERS_COMMAND_CONFIG.get("select_related", []),
75+
)
76+
.prefetch_related(
77+
*app_settings.EXPORT_USERS_COMMAND_CONFIG.get("prefetch_related", []),
78+
)
79+
.order_by("date_joined")
80+
)
4281

4382
# Prepare a CSV writer
4483
filename = options.get("filename")
45-
csv_file = open(filename, "w", newline="")
46-
csv_writer = csv.writer(csv_file)
47-
48-
# Write header row
49-
csv_writer.writerow(fields)
50-
51-
# Write data rows
52-
for user in queryset.iterator():
53-
data_row = []
54-
for field in fields:
55-
# Extract the value from related models
56-
if "." in field:
57-
related_model, related_field = field.split(".")
58-
try:
59-
related_value = getattr(
60-
getattr(user, related_model), related_field
61-
)
62-
except ObjectDoesNotExist:
63-
data_row.append("")
64-
else:
65-
data_row.append(related_value)
66-
elif field == "organizations":
67-
organizations = []
68-
for org_id, user_perm in user.organizations_dict.items():
69-
organizations.append(f'({org_id},{user_perm["is_admin"]})')
70-
data_row.append("\n".join(organizations))
71-
else:
72-
data_row.append(getattr(user, field))
73-
csv_writer.writerow(data_row)
74-
75-
# Close the CSV file
76-
csv_file.close()
84+
with open(filename, "w", newline="") as csv_file:
85+
csv_writer = csv.writer(csv_file)
86+
# Write header row using the name of each field
87+
csv_writer.writerow([normalize_field(f)["name"] for f in fields])
88+
89+
# Write data rows
90+
for user in queryset.iterator():
91+
csv_writer.writerow(
92+
[self._get_field_value(user, field) for field in fields]
93+
)
7794
self.stdout.write(
7895
self.style.SUCCESS(f"User data exported successfully to {filename}!")
7996
)
97+
98+
def _get_field_value(self, user, field):
99+
normalized = normalize_field(field)
100+
name = normalized["name"]
101+
callable_fn = normalized.get("callable")
102+
subfields = normalized.get("fields")
103+
# Priority: callable > fields > name
104+
if callable_fn is not None:
105+
try:
106+
return callable_fn(user)
107+
except Exception as e:
108+
raise Exception(f"Error calling function for field '{name}': {e}")
109+
if subfields is not None:
110+
attr = resolve_attr(user, name)
111+
if attr is None:
112+
return ""
113+
if hasattr(attr, "iterator"):
114+
return serialize_related(attr, subfields)
115+
return ",".join(str(getattr(attr, f, "")) for f in subfields)
116+
117+
# Dot-notation: e.g. "auth_token.key" or "profile.phone_number"
118+
if "." in name:
119+
model_attr, sub_attr = name.split(".", 1)
120+
try:
121+
intermediate = getattr(user, model_attr)
122+
except ObjectDoesNotExist:
123+
return ""
124+
if hasattr(intermediate, "iterator"):
125+
# Related manager accessed via dot notation → comma-separated values
126+
return ",".join(
127+
str(getattr(obj, sub_attr, "")) for obj in intermediate.iterator()
128+
)
129+
try:
130+
return getattr(intermediate, sub_attr)
131+
except ObjectDoesNotExist:
132+
return ""
133+
return getattr(user, name)

openwisp_users/settings.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
AUTH_BACKEND_AUTO_PREFIXES = getattr(
1414
settings, "OPENWISP_USERS_AUTH_BACKEND_AUTO_PREFIXES", tuple()
1515
)
16+
17+
18+
def _export_organizations(user):
19+
return ",".join(
20+
f'({org_id},{perm["is_admin"]})'
21+
for org_id, perm in user.organizations_dict.items()
22+
)
23+
24+
1625
EXPORT_USERS_COMMAND_CONFIG = {
1726
"fields": [
1827
"id",
@@ -29,9 +38,10 @@
2938
"location",
3039
"notes",
3140
"language",
32-
"organizations",
41+
{"name": "organizations", "callable": _export_organizations},
3342
],
3443
"select_related": [],
44+
"prefetch_related": [],
3545
}
3646
USER_PASSWORD_EXPIRATION = getattr(
3747
settings, "OPENWISP_USERS_USER_PASSWORD_EXPIRATION", 0

0 commit comments

Comments
 (0)