Skip to content

[feature] Add extensible field serialization to export_users command #497#498

Open
pandafy wants to merge 10 commits intomasterfrom
issues/497-export-users
Open

[feature] Add extensible field serialization to export_users command #497#498
pandafy wants to merge 10 commits intomasterfrom
issues/497-export-users

Conversation

@pandafy
Copy link
Copy Markdown
Member

@pandafy pandafy commented Apr 13, 2026

Checklist

  • I have read the OpenWISP Contributing Guidelines.
  • I have manually tested the changes proposed in this pull request.
  • I have written new test cases for new code and/or updated existing tests for changes to existing code.
  • I have updated the documentation.

Reference to Existing Issue

Closes #497

Description of Changes

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.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The export_users management command normalizes configured export fields (strings or dicts) via normalize_field, supports select_related and prefetch_related on the queryset, streams users with iterator(chunk_size=1000), and writes CSV with UTF-8 handling using normalized headers. Per-user extraction is centralized in Command._get_field_value, which prefers a callable, then structured fields resolution (using serialize_related for managers/querysets), then dotted-attribute lookup via _get_nested_attr. Settings add _export_organizations and an explicit default prefetch_related: []. Tests were extended to cover these behaviors and error handling.

Sequence Diagram(s)

sequenceDiagram
participant CLI as Management Command (export_users)
participant Cmd as Command
participant Norm as normalize_field
participant DB as ORM/Database
participant Resolver as _get_field_value
participant Callable as Field callable
participant Serializer as serialize_related/_get_nested_attr
participant CSV as CSV writer

CLI->>Cmd: run export_users
Cmd->>Norm: load & normalize config (fields, select_related, prefetch_related)
Cmd->>DB: prepare queryset (.select_related/.prefetch_related)
DB-->>Cmd: ready queryset
Cmd->>DB: iterate queryset (iterator(chunk_size=1000)) -> user
loop per user
    Cmd->>Resolver: for each normalized field
    alt field has callable
        Resolver->>Callable: call(user)
        Callable-->>Resolver: value or exception
    else field has nested fields
        Resolver->>Serializer: fetch related manager/attrs -> serialized value
        Serializer-->>Resolver: value
    else dot-notation attribute
        Resolver->>Serializer: resolve attribute chain -> value or ''
        Serializer-->>Resolver: value
    end
    Resolver-->>Cmd: field value
    Cmd->>CSV: write row value
end
CSV-->>CLI: output file/stdout
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding extensible field serialization to the export_users command, with a reference to issue #497.
Description check ✅ Passed The description covers all required sections: checklist completed, linked issue referenced, clear description of changes, and follows the template structure.
Linked Issues check ✅ Passed The PR fully implements the objectives from issue #497: extensible field definitions supporting callables, related object extraction, dot-notation traversal, and prefetch_related optimization to replace hardcoded handling.
Out of Scope Changes check ✅ Passed All changes are directly in scope: export_users refactoring, settings configuration updates, documentation expansion, and comprehensive test additions align with issue #497 requirements.
Bug Fixes ✅ Passed This PR implements new features for extensible field serialization, not bug fixes, making the bug fix check inapplicable.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issues/497-export-users

Comment @coderabbitai help to get the list of available commands and usage tips.

@pandafy pandafy force-pushed the issues/497-export-users branch 2 times, most recently from b9f3cc9 to 45682eb Compare April 13, 2026 17:40
@pandafy pandafy changed the title [users] Add extensible field serialization to export_users command #497 [feature] Add extensible field serialization to export_users command #497 Apr 13, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 19-24: resolve_attr currently documents returning None for missing
attributes but only catches ObjectDoesNotExist, so AttributeError still bubbles
up; update resolve_attr to handle both missing-attribute and DB-missing cases by
either using getattr(obj, attr_name, None) or wrapping getattr in a try/except
that catches (ObjectDoesNotExist, AttributeError) and returns None; reference
resolve_attr, getattr and ObjectDoesNotExist when making the change.
- Around line 105-109: The current exception handler around callable_fn(user)
raises a generic Exception with a non-translated message; change it to raise
django.core.management.CommandError and wrap the message with gettext (e.g.
gettext or alias _) so it is translatable. Update the import list to include
CommandError from django.core.management and gettext from
django.utils.translation, then replace the raise Exception(...) in the except
block inside the function that calls callable_fn (look for callable_fn and the
surrounding try/except) with raise CommandError(_("Error calling function for
field '%s': %s") % (name, e)) or equivalent translatable string formatting.
- Line 33: Remove the use of .iterator() which bypasses
select_related/prefetch_related: replace any manager.iterator() and
RelatedManager.iterator() usages (e.g., the list comprehension creating rows
that uses manager.iterator(), plus the other occurrences referenced) with plain
iteration over the queryset/manager (just manager or queryset) so
prefetching/selection takes effect; if chunked iteration is truly required, use
.iterator(chunk_size=...) explicitly, but prefer removing .iterator() to keep
prefetch_related/select_related benefits in export_users.py (look for the
variables manager, subfields, and the RelatedManager usages to locate the
spots).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3808624e-0d36-4926-82bb-56f335b220f2

📥 Commits

Reviewing files that changed from the base of the PR and between 2ef104e and b9f3cc9.

📒 Files selected for processing (4)
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=4.2.0
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
  • openwisp_users/management/commands/export_users.py
🔇 Additional comments (3)
openwisp_users/settings.py (1)

18-23: Good extensibility update for organizations export config.

The callable-based organizations field and explicit prefetch_related config align well with the new generic export pipeline.

Also applies to: 41-44

docs/user/settings.rst (1)

114-199: Docs are consistent with the new field normalization and relation export behavior.

The updated examples and priority rules clearly reflect the current command configuration model.

openwisp_users/tests/test_commands.py (1)

50-54: Test coverage improvements are solid.

The normalized-header assertions, constant-query test, and callable-failure case materially improve confidence in the new export behavior.

Also applies to: 118-143, 145-162

Comment thread openwisp_users/management/commands/export_users.py Outdated
Comment thread openwisp_users/management/commands/export_users.py Outdated
Comment thread openwisp_users/management/commands/export_users.py
@github-project-automation github-project-automation Bot moved this from To do (general) to In progress in OpenWISP Contributor's Board Apr 13, 2026
@openwisp-companion
Copy link
Copy Markdown

Commit Message Format Failure

Hello @pandafy,
(Analysis for commit b9f3cc9)

The CI failed because the commit message does not adhere to the required format.

Fix:
Please reformat your commit message to follow the OpenWISP conventions. The header should include a tag, a capitalized title, and optionally an issue number. The body should be separated by a blank line, and the footer should use a closing keyword like Fixes or Closes followed by the issue number.

Here's an example of the correct format:

[feature] 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.

Fixes #497

@coveralls
Copy link
Copy Markdown

coveralls commented Apr 13, 2026

Coverage Status

coverage: 98.022% (+0.1%) from 97.899% — issues/497-export-users into master

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
openwisp_users/management/commands/export_users.py (3)

19-24: ⚠️ Potential issue | 🟡 Minor

Catch AttributeError here too.

The docstring says missing attributes return None, but getattr() on an unknown field still raises and aborts the export because only ObjectDoesNotExist is handled.

Proposed fix
 def resolve_attr(obj, attr_name):
     """Return the attribute value on obj, or None if it does not exist."""
     try:
         return getattr(obj, attr_name)
-    except ObjectDoesNotExist:
+    except (ObjectDoesNotExist, AttributeError):
         return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 19 - 24, The
resolve_attr helper currently only catches ObjectDoesNotExist, so getattr(obj,
attr_name) can still raise AttributeError and abort exports; update resolve_attr
to also catch AttributeError (in addition to ObjectDoesNotExist) and return None
when either exception occurs so missing attributes behave as documented; locate
the resolve_attr function and extend its except clause to handle AttributeError
for safe attribute resolution.

104-108: ⚠️ Potential issue | 🟠 Major

Raise CommandError and translate the failure message.

This is a user-facing management-command error, but it is emitted as a generic Exception with a hard-coded English string. Use CommandError and wrap the message with gettext.

Proposed fix
-from django.core.management.base import BaseCommand
+from django.core.management.base import BaseCommand, CommandError
+from django.utils.translation import gettext as _
@@
         if callable_fn is not None:
             try:
                 return callable_fn(user)
             except Exception as e:
-                raise Exception(f"Error calling function for field '{name}': {e}")
+                raise CommandError(
+                    _("Error calling function for field '%(field)s': %(error)s")
+                    % {"field": name, "error": e}
+                ) from e

As per coding guidelines: For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 104 - 108,
Replace the generic Exception with Django's CommandError and make the message
translatable: import CommandError from django.core.management and gettext (e.g.
from django.utils.translation import gettext as _), then change the raise to
raise CommandError(_("Error calling function for field '%s': %s") % (name, e))
so the management command surfaces a CommandError and the message is wrapped for
i18n; update the exception handling around callable_fn (the block with
callable_fn, user, name) accordingly.

27-38: ⚠️ Potential issue | 🟠 Major

Remove .iterator() from the prefetched paths.

These iterations bypass Django’s prefetched cache, so the new prefetch_related option will not actually prevent N+1 queries on the code paths this PR adds.

Proposed fix
-    rows = [[str(getattr(obj, f, "")) for f in subfields] for obj in manager.iterator()]
+    rows = [[str(getattr(obj, f, "")) for f in subfields] for obj in manager]
-            for user in queryset.iterator():
+            for user in queryset:
                 csv_writer.writerow(
                     [self._get_field_value(user, field) for field in fields]
                 )
-                return ",".join(
-                    str(getattr(obj, sub_attr, "")) for obj in intermediate.iterator()
-                )
+                return ",".join(
+                    str(getattr(obj, sub_attr, "")) for obj in intermediate
+                )

Also applies to: 89-93, 124-128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 27 - 38, The
serialize_related function (and the other similar blocks at the other ranges) is
iterating via manager.iterator(), which bypasses Django's prefetched_related
cache and causes N+1 queries; change the iteration to use the cached queryset
(e.g. iterate over manager.all() or the manager itself) instead of calling
.iterator() so the prefetched objects are used (update serialize_related and the
analogous loops at the other locations to remove .iterator()).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 117-131: The dot-notation handling in export_users.py only splits
the first segment using split(".", 1) causing multi-hop paths like
"registered_user.organization.name" to break; replace the single-split logic in
the block that uses name, model_attr, sub_attr and intermediate with iterative
traversal: split name into segments = name.split(".") and loop through segments
resolving getattr at each hop, catching ObjectDoesNotExist and returning "" when
encountered; if an intermediate object has an "iterator" (related manager) and
there are remaining segments, map the remaining attribute path across each
related object (joining comma-separated string results) rather than trying to
getattr the manager itself; ensure the final resolved value is returned (or ""
when missing) and remove the split(".", 1) usage.
- Around line 64-70: The exclude_fields list uses
options.get("exclude_fields").split(",") which preserves whitespace and empty
tokens, so names like " email" won't match; update the parsing of exclude_fields
(in the export/users command where exclude_fields is defined and used alongside
normalize_field and the fields list comprehension) to strip each token and
discard empty strings (e.g., build exclude_fields = [t.strip() for t in
options.get("exclude_fields").split(",") if t.strip()]) before using
normalize_field(field)["name"] not in exclude_fields to filter fields.

In `@openwisp_users/settings.py`:
- Around line 18-22: The current _export_organizations() uses
user.organizations_dict which forces per-user DB lookups; change it to prefer
serializing from the prefetched relation (e.g., iterate over
user.organizations.all() so prefetch_related results are used) and only fall
back to user.organizations_dict if the relation isn't present, and apply the
same change to the other organizations serializer at the referenced block (lines
~41-44). Update _export_organizations and the second serializer to build the
"(org_id,is_admin)" strings from the prefetched organization objects (or a safe
fallback to organizations_dict) so the built-in organizations column benefits
from bulk prefetching.

In `@openwisp_users/tests/test_commands.py`:
- Around line 118-143: Update the test to exercise the prefetch_related /
related-manager serialization branch by modifying or adding a test similar to
test_related_fields_no_n_plus_1 that sets
app_settings.EXPORT_USERS_COMMAND_CONFIG to include a prefetch_related entry and
a related-field descriptor (e.g. {"name": "groups", "fields": ["name"]} or
similar) instead of only using select_related with "auth_token.key"; then create
related objects (e.g. add users to a group or create multiple related items),
call call_command("export_users", filename=...), assert the query count remains
constant (use assertNumQueries) and verify the exported CSV includes the
serialized related-manager fields for each user so the prefetch path is actually
exercised.

---

Duplicate comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 19-24: The resolve_attr helper currently only catches
ObjectDoesNotExist, so getattr(obj, attr_name) can still raise AttributeError
and abort exports; update resolve_attr to also catch AttributeError (in addition
to ObjectDoesNotExist) and return None when either exception occurs so missing
attributes behave as documented; locate the resolve_attr function and extend its
except clause to handle AttributeError for safe attribute resolution.
- Around line 104-108: Replace the generic Exception with Django's CommandError
and make the message translatable: import CommandError from
django.core.management and gettext (e.g. from django.utils.translation import
gettext as _), then change the raise to raise CommandError(_("Error calling
function for field '%s': %s") % (name, e)) so the management command surfaces a
CommandError and the message is wrapped for i18n; update the exception handling
around callable_fn (the block with callable_fn, user, name) accordingly.
- Around line 27-38: The serialize_related function (and the other similar
blocks at the other ranges) is iterating via manager.iterator(), which bypasses
Django's prefetched_related cache and causes N+1 queries; change the iteration
to use the cached queryset (e.g. iterate over manager.all() or the manager
itself) instead of calling .iterator() so the prefetched objects are used
(update serialize_related and the analogous loops at the other locations to
remove .iterator()).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ea1b5758-1b5e-4dc9-94a7-bdc7548bb71e

📥 Commits

Reviewing files that changed from the base of the PR and between b9f3cc9 and 45682eb.

📒 Files selected for processing (4)
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (16)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (python)
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=5.1.0
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
  • openwisp_users/management/commands/export_users.py
🧠 Learnings (2)
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior

Applied to files:

  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Applies to **/*.py : For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Applied to files:

  • openwisp_users/management/commands/export_users.py

Comment thread openwisp_users/management/commands/export_users.py Outdated
Comment thread openwisp_users/management/commands/export_users.py Outdated
Comment thread openwisp_users/settings.py Outdated
Comment thread openwisp_users/tests/test_commands.py Outdated
@pandafy pandafy force-pushed the issues/497-export-users branch from 45682eb to d9c6199 Compare April 13, 2026 18:58
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
openwisp_users/management/commands/export_users.py (3)

119-123: ⚠️ Potential issue | 🟠 Major

Using .iterator() on RelatedManager bypasses prefetch_related benefits.

Line 122 calls intermediate.iterator() on a RelatedManager, which executes a fresh query and ignores the prefetched cache. This negates the N+1 optimization when using prefetch_related for reverse relations accessed via dot notation.

🔧 Proposed fix
             if hasattr(intermediate, "iterator"):
                 # Related manager accessed via dot notation → comma-separated values
                 return ",".join(
-                    str(getattr(obj, sub_attr, "")) for obj in intermediate.iterator()
+                    str(getattr(obj, sub_attr, "")) for obj in intermediate.all()
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 119 - 123,
The current code calls intermediate.iterator() on a RelatedManager (variable
intermediate) which bypasses any prefetched cache; replace the iterator() call
with iteration over intermediate.all() (or simply intermediate) so that
prefetch_related results are used: update the generator expression in
export_users/management/commands/export_users.py to iterate over
intermediate.all() (or the manager directly) instead of intermediate.iterator(),
preserving the comma-joined string behavior.

56-62: ⚠️ Potential issue | 🟡 Minor

Whitespace in --exclude-fields values will cause matching failures.

split(",") preserves whitespace, so --exclude-fields="id, email" produces ["id", " email"] and " email" won't match "email".

🔧 Proposed fix
-        exclude_fields = options.get("exclude_fields").split(",")
+        exclude_fields = [
+            f.strip() for f in options.get("exclude_fields", "").split(",") if f.strip()
+        ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 56 - 62, The
exclude_fields list produced by options.get("exclude_fields").split(",") can
contain leading/trailing whitespace so matches against normalized field names
fail; trim whitespace and ignore empty entries by mapping strip() and filtering
out falsy values when constructing exclude_fields before the list comprehension
that filters fields (refer to options.get("exclude_fields"), exclude_fields,
normalize_field, and the fields list comprehension) so comparisons use cleaned
names like "email" instead of " email".

96-100: ⚠️ Potential issue | 🟠 Major

Raise CommandError with a translatable message.

The exception at line 100 uses a generic Exception with a user-facing string that is not marked for translation. Management commands should use CommandError, and per Django i18n guidelines, user-facing strings must use gettext.

🔧 Proposed fix
+from django.core.management.base import BaseCommand, CommandError
+from django.utils.translation import gettext as _
 
 # In _get_field_value:
         if callable_fn is not None:
             try:
                 return callable_fn(user)
             except Exception as e:
-                raise Exception(f"Error calling function for field '{name}': {e}")
+                raise CommandError(
+                    _("Error calling function for field '%(field)s': %(error)s")
+                    % {"field": name, "error": e}
+                ) from e

Based on learnings: For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 96 - 100,
The current exception raised when callable_fn(user) fails uses a plain Exception
and an untranslated message; change this to raise
django.core.management.CommandError with a translatable message using gettext
(e.g. from django.utils.translation import gettext as _). Update the except
block in export_users.py (the callable_fn handling) to catch Exception as e and
raise CommandError(_("Error calling function for field '%(name)s': %(err)s") %
{"name": name, "err": str(e)}), and add the necessary imports for CommandError
and gettext at the top of the file.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 112-127: The current dot-notation handling in export_users (the
split(".", 1) that produces model_attr and sub_attr and uses intermediate) only
supports two-level paths; update the logic to iterate through all path segments
instead of a single split so paths like registered_user.organization.name
traverse each attribute in turn (handle related managers/iterables at any level
by checking hasattr(intermediate, "iterator") and joining values when
encountered), or if you choose not to implement traversal, add a clear
comment/docstring near the dot-notation handling explaining the deliberate
two-level limitation (referencing split(".", 1), model_attr, sub_attr, and
intermediate).

In `@openwisp_users/tests/test_commands.py`:
- Around line 164-190: The decorator for `@patch.object` setting
app_settings.EXPORT_USERS_COMMAND_CONFIG references app_label (e.g.,
f"{app_label}_organizationuser") which is not in scope at decoration time; fix
by replacing those f-strings with the actual literal string
"openwisp_users_organizationuser" (or move app_label to module-level and
reference that constant) so the decorator no longer relies on a class attribute;
update the entries in the EXPORT_USERS_COMMAND_CONFIG fields list and any
duplicated manager entries accordingly.

---

Duplicate comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 119-123: The current code calls intermediate.iterator() on a
RelatedManager (variable intermediate) which bypasses any prefetched cache;
replace the iterator() call with iteration over intermediate.all() (or simply
intermediate) so that prefetch_related results are used: update the generator
expression in export_users/management/commands/export_users.py to iterate over
intermediate.all() (or the manager directly) instead of intermediate.iterator(),
preserving the comma-joined string behavior.
- Around line 56-62: The exclude_fields list produced by
options.get("exclude_fields").split(",") can contain leading/trailing whitespace
so matches against normalized field names fail; trim whitespace and ignore empty
entries by mapping strip() and filtering out falsy values when constructing
exclude_fields before the list comprehension that filters fields (refer to
options.get("exclude_fields"), exclude_fields, normalize_field, and the fields
list comprehension) so comparisons use cleaned names like "email" instead of "
email".
- Around line 96-100: The current exception raised when callable_fn(user) fails
uses a plain Exception and an untranslated message; change this to raise
django.core.management.CommandError with a translatable message using gettext
(e.g. from django.utils.translation import gettext as _). Update the except
block in export_users.py (the callable_fn handling) to catch Exception as e and
raise CommandError(_("Error calling function for field '%(name)s': %(err)s") %
{"name": name, "err": str(e)}), and add the necessary imports for CommandError
and gettext at the top of the file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4a1ba6e9-50e0-4163-9aaa-30dad6a149aa

📥 Commits

Reviewing files that changed from the base of the PR and between 45682eb and d9c6199.

📒 Files selected for processing (4)
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (17)
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (python)
  • GitHub Check: Analyze (actions)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
  • openwisp_users/management/commands/export_users.py
🧠 Learnings (2)
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior

Applied to files:

  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Applies to **/*.py : For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Applied to files:

  • openwisp_users/management/commands/export_users.py
🔇 Additional comments (6)
openwisp_users/settings.py (1)

18-22: The _export_organizations callable still triggers per-user database queries.

The function accesses user.organizations_dict, which (per openwisp_users/base/models.py:128-155) executes a query scoped to a single user. With prefetch_related: [] at line 44, the N+1 mitigation introduced by this PR is not leveraged for the built-in organizations column.

Consider iterating over user.openwisp_users_organizationuser_set.all() (or the appropriate reverse relation) and adding that relation to prefetch_related so the prefetched cache is used.

docs/user/settings.rst (1)

84-198: LGTM!

The documentation comprehensively covers the new field definition schema, priority rules, related object handling, and query optimization options with clear examples.

openwisp_users/tests/test_commands.py (2)

145-162: LGTM on error handling test coverage.

The test correctly validates that callable errors propagate with the expected message format "Error calling function for field 'broken'".


220-234: LGTM on ObjectDoesNotExist edge case test.

Good unit test coverage for the dot-notation path when sub-attribute access raises ObjectDoesNotExist.

openwisp_users/management/commands/export_users.py (2)

12-16: LGTM on helper functions.

normalize_field and serialize_related are clean implementations. serialize_related correctly uses .all() to leverage prefetched results.

Also applies to: 19-30


82-85: No changes needed — iterator with chunk_size and prefetch_related is appropriate.

The code correctly uses prefetch_related() before iterator(chunk_size=1000). Django 4.2+ (the minimum version tested in CI) fully supports this pattern: prefetched objects load per chunk as designed, making it well-suited for memory-efficient bulk exports.

Comment thread openwisp_users/management/commands/export_users.py Outdated
Comment thread openwisp_users/tests/test_commands.py
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (4)
openwisp_users/management/commands/export_users.py (3)

121-125: ⚠️ Potential issue | 🟠 Major

Reuse the prefetched related manager here.

prefetch_related() populates the related manager's cached queryset and Django reuses that via related_manager.all(). QuerySet.iterator() is a direct database iterator, so intermediate.iterator() bypasses the prefetched cache and can reintroduce one query per exported user. (docs.djangoproject.com)

⚡ Suggested fix
-                return ",".join(
-                    str(getattr(obj, sub_attr, "")) for obj in intermediate.iterator()
-                )
+                return ",".join(
+                    str(getattr(obj, sub_attr, "")) for obj in intermediate.all()
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 121 - 125,
The current export loop uses intermediate.iterator(), which bypasses
prefetch_related cache and can issue extra queries; change it to iterate the
prefetched related manager by calling intermediate.all() (i.e., replace
intermediate.iterator() with intermediate.all()) so the generator expression
using getattr(obj, sub_attr, "") reuses the cached queryset from the related
manager.

98-103: ⚠️ Potential issue | 🟠 Major

Translate the new CommandError message.

This is a new user-facing Django command string, but it is still hard-coded English.

🌐 Suggested fix
 from django.core.management.base import BaseCommand, CommandError
+from django.utils.translation import gettext as _
@@
             try:
                 return callable_fn(user)
             except Exception as e:
-                raise CommandError(f"Error calling function for field '{name}': {e}")
+                raise CommandError(
+                    _("Error calling function for field '%(field)s': %(error)s")
+                    % {"field": name, "error": e}
+                ) from e
As per coding guidelines, "For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 98 - 103,
The CommandError message raised in the callable_fn branch is a new user-facing
string and must be made translatable: import gettext as _ from
django.utils.translation (or use gettext_lazy) at the top of the module, then
replace the hard-coded f-string in the raise inside the callable_fn block (the
code that currently does raise CommandError(f"Error calling function for field
'{name}': {e}")) with a translatable message using _(), e.g. use a translation
string with placeholders and interpolate name and the exception (e.g. _("Error
calling function for field '%(name)s': %(error)s") % {"name": name, "error": e})
so the error text is internationalized.

115-128: ⚠️ Potential issue | 🟠 Major

split(".", 1) still limits dot notation to one hop.

registered_user.organization.name becomes getattr(registered_user, "organization.name") after the first step, so anything deeper than a.b still fails. The traversal needs to walk all remaining segments, including when a related manager appears mid-path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 115 - 128,
The current dot-traversal only splits once (split(".", 1)) and fails for deeper
paths; rewrite the traversal to iterate over all segments (parts =
name.split(".")) walking a current value starting from user, at each segment
doing getattr and catching ObjectDoesNotExist to return "" if missing; if the
current value has an iterator (related manager) while there are remaining
segments, collect a comma-separated list by iterating over the manager and using
getattr on each object for the remaining joined subpath, otherwise continue
walking segments until returning the final attribute value. Ensure you update
uses of variables like model_attr/sub_attr/intermediate to the iterative
approach and preserve existing exception handling and return types.
openwisp_users/tests/test_commands.py (1)

163-199: ⚠️ Potential issue | 🟠 Major

Add a query-budget assertion for the prefetched manager path.

This is the only test that exercises prefetch_related with related-manager serialization, but call_command() still isn't wrapped in assertNumQueries. The current manager dot-notation path can regress back to one extra query per user and this test will still pass.

🧪 Suggested test tightening
-        call_command("export_users", filename=self.temp_file.name)
+        with self.assertNumQueries(2):
+            call_command("export_users", filename=self.temp_file.name)
Based on learnings: "Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/tests/test_commands.py` around lines 163 - 199, The test
test_subfields_dict_field doesn't assert query count for the prefetched manager
path, so wrap the call_command("export_users", ...) in an assertNumQueries
context to ensure openwisp_users_organizationuser is prefetched and no per-user
extra queries occur; locate the test_subfields_dict_field function and the
EXPORT_USERS_COMMAND_CONFIG patch and add a call to
self.assertNumQueries(<expected_count>) (or assertNumQueries from django.test)
around call_command("export_users", filename=self.temp_file.name) to lock the
expected query budget for the manager dot-notation path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 56-64: The exclusion logic is matching only
normalize_field(field)["name"], which is ambiguous for dict/related fields;
change it to compute and match against a unique field identifier (e.g., the full
normalized path or column key returned by normalize_field such as
normalize_field(field)["path"] or ["key"]) wherever fields are filtered (the
exclude_fields parsing and the list comprehension that builds fields) and use
the same unique identifier when you build CSV headers (the corresponding header
generation code referenced around lines 80-81) so that each column can be
excluded independently; also accept legacy plain names by falling back to
matching the old "name" if no full identifier is provided.

In `@openwisp_users/tests/test_commands.py`:
- Line 13: The test currently imports and uses normalize_field() from
export_users which mirrors the implementation and can hide regressions; remove
the import of normalize_field and stop calling it in the test oracle, and
instead hard-code the expected header names and exclude lists directly in the
assertions (replace uses at the spots around normalize_field, and where Command
output is compared at lines ~50-54, 71-72, 110-114) so the test asserts explicit
expected values independent of the command's normalize_field implementation.

---

Duplicate comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 121-125: The current export loop uses intermediate.iterator(),
which bypasses prefetch_related cache and can issue extra queries; change it to
iterate the prefetched related manager by calling intermediate.all() (i.e.,
replace intermediate.iterator() with intermediate.all()) so the generator
expression using getattr(obj, sub_attr, "") reuses the cached queryset from the
related manager.
- Around line 98-103: The CommandError message raised in the callable_fn branch
is a new user-facing string and must be made translatable: import gettext as _
from django.utils.translation (or use gettext_lazy) at the top of the module,
then replace the hard-coded f-string in the raise inside the callable_fn block
(the code that currently does raise CommandError(f"Error calling function for
field '{name}': {e}")) with a translatable message using _(), e.g. use a
translation string with placeholders and interpolate name and the exception
(e.g. _("Error calling function for field '%(name)s': %(error)s") % {"name":
name, "error": e}) so the error text is internationalized.
- Around line 115-128: The current dot-traversal only splits once (split(".",
1)) and fails for deeper paths; rewrite the traversal to iterate over all
segments (parts = name.split(".")) walking a current value starting from user,
at each segment doing getattr and catching ObjectDoesNotExist to return "" if
missing; if the current value has an iterator (related manager) while there are
remaining segments, collect a comma-separated list by iterating over the manager
and using getattr on each object for the remaining joined subpath, otherwise
continue walking segments until returning the final attribute value. Ensure you
update uses of variables like model_attr/sub_attr/intermediate to the iterative
approach and preserve existing exception handling and return types.

In `@openwisp_users/tests/test_commands.py`:
- Around line 163-199: The test test_subfields_dict_field doesn't assert query
count for the prefetched manager path, so wrap the call_command("export_users",
...) in an assertNumQueries context to ensure openwisp_users_organizationuser is
prefetched and no per-user extra queries occur; locate the
test_subfields_dict_field function and the EXPORT_USERS_COMMAND_CONFIG patch and
add a call to self.assertNumQueries(<expected_count>) (or assertNumQueries from
django.test) around call_command("export_users", filename=self.temp_file.name)
to lock the expected query budget for the manager dot-notation path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 64e1b1c0-7b6f-428f-b8ae-e298642a9639

📥 Commits

Reviewing files that changed from the base of the PR and between d9c6199 and 852e204.

📒 Files selected for processing (2)
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/tests/test_commands.py
  • openwisp_users/management/commands/export_users.py
🧠 Learnings (2)
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior

Applied to files:

  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Applies to **/*.py : For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Applied to files:

  • openwisp_users/management/commands/export_users.py

Comment thread openwisp_users/management/commands/export_users.py
Comment thread openwisp_users/tests/test_commands.py Outdated
@pandafy
Copy link
Copy Markdown
Member Author

pandafy commented Apr 14, 2026

@coderabbitai review!

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

@pandafy Sure! I'll review the changes now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
openwisp_users/management/commands/export_users.py (2)

118-122: ⚠️ Potential issue | 🟡 Minor

Error message should be marked for translation.

Per Django i18n guidelines, the error message string should use gettext.

Proposed fix
             except Exception as e:
-                raise CommandError(f"Error calling function for field '{name}': {e}")
+                raise CommandError(
+                    _("Error calling function for field '%(field)s': %(error)s")
+                    % {"field": name, "error": e}
+                ) from e

As per coding guidelines: "For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 118 - 122,
The error message raised in the callable wrapper should be marked for
translation: import gettext (e.g. from django.utils.translation import gettext
as _) and wrap the CommandError message string with _(), changing the raise in
the exception handler in the function that invokes callable_fn (the block
referencing callable_fn, user, name and raising CommandError) to use _("Error
calling function for field '%s': %s") or similar with formatting using name and
the exception; ensure the gettext import is added at the top of the module
(export_users.py) so the translated string is used.

104-109: ⚠️ Potential issue | 🟠 Major

Using .iterator() on RelatedManager bypasses prefetch_related cache.

When current is a RelatedManager and .iterator() is called, it executes a new database query instead of using the prefetched results cache. This causes N+1 queries when traversing multi-hop dot-notation paths through prefetched relations.

Proposed fix
             if hasattr(current, "iterator") and i < len(parts) - 1:
                 remaining_path = ".".join(parts[i + 1 :])
                 return ",".join(
                     str(self._get_nested_attr(item, remaining_path))
-                    for item in current.iterator()
+                    for item in current.all()
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 104 - 109,
The code calls current.iterator() inside _get_nested_attr when traversing
multi-hop relations, which bypasses prefetch_related cache and causes N+1
queries; change the iteration to use the RelatedManager queryset (e.g.,
current.all() or simply iter(current.all())) so prefetched results are used,
i.e., in the block referencing current, parts, remaining_path and
_get_nested_attr replace current.iterator() with current.all() (or
list(current.all()) if strict materialization is needed) to avoid making new DB
queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 131-134: The conditional in export_users.py that checks if "." in
name is redundant because both branches call the same helper; simplify by
removing the if/else and directly return self._get_nested_attr(user, name) in
the containing method (referencing the same _get_nested_attr helper and the
local variable name/user) so behavior remains unchanged and the dead branch is
eliminated.
- Around line 64-76: The success message written via
self.stdout.write(self.style.SUCCESS(f"User data exported successfully to
{filename}!")) is not marked for translation; import gettext as _ from
django.utils.translation and wrap the user-facing string passed to
self.style.SUCCESS with _(…) so the message becomes translatable (keeping the
filename interpolation), and ensure the import is added near other imports in
the module.

---

Duplicate comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 118-122: The error message raised in the callable wrapper should
be marked for translation: import gettext (e.g. from django.utils.translation
import gettext as _) and wrap the CommandError message string with _(), changing
the raise in the exception handler in the function that invokes callable_fn (the
block referencing callable_fn, user, name and raising CommandError) to use
_("Error calling function for field '%s': %s") or similar with formatting using
name and the exception; ensure the gettext import is added at the top of the
module (export_users.py) so the translated string is used.
- Around line 104-109: The code calls current.iterator() inside _get_nested_attr
when traversing multi-hop relations, which bypasses prefetch_related cache and
causes N+1 queries; change the iteration to use the RelatedManager queryset
(e.g., current.all() or simply iter(current.all())) so prefetched results are
used, i.e., in the block referencing current, parts, remaining_path and
_get_nested_attr replace current.iterator() with current.all() (or
list(current.all()) if strict materialization is needed) to avoid making new DB
queries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 37dae2c7-95f1-4008-97e7-48235fba4f92

📥 Commits

Reviewing files that changed from the base of the PR and between 852e204 and c7b6326.

📒 Files selected for processing (2)
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (18)
  • GitHub Check: CodeQL
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (python)
  • GitHub Check: Analyze (actions)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/tests/test_commands.py
  • openwisp_users/management/commands/export_users.py
🧠 Learnings (2)
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior

Applied to files:

  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Applies to **/*.py : For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Applied to files:

  • openwisp_users/management/commands/export_users.py
🔇 Additional comments (12)
openwisp_users/tests/test_commands.py (8)

50-70: LGTM! Explicit expected headers avoid mirroring production code.

The hardcoded expected_headers list correctly matches the default EXPORT_USERS_COMMAND_CONFIG field names and ensures the test validates explicit expectations rather than depending on the normalize_field implementation.


76-78: LGTM! Organizations format assertion matches the callable output.

The assertion now correctly expects the comma-separated tuple format ({org_id},True),({org_id},False) which aligns with the _export_organizations callable defined in settings.


86-104: LGTM! Explicit exclude_fields list avoids production code coupling.

The hardcoded field names ensure the test validates exclusion behavior independently of normalize_field.


114-146: LGTM! Test correctly validates select_related optimization.

The patched config with select_related: ["auth_token"] and prefetch_related: [] properly tests dot-notation field extraction with only 1 query.


148-172: LGTM! Test validates N+1 query prevention and ObjectDoesNotExist handling.

The test correctly verifies that query count remains constant with multiple users and that missing related objects produce empty CSV cells.


174-192: LGTM! Callable error handling test validates exception propagation.

The test correctly verifies that callable failures raise an exception with the expected error message format.


194-249: LGTM! Comprehensive test for dict-field subfields and manager serialization.

This test exercises the prefetch_related path with openwisp_users_organizationuser, covers:

  • Single related object with subfields (auth_token)
  • Nullable field subfields (birth_date)
  • Manager with single/multiple subfields
  • Dot-notation on managers
  • Empty manager and ObjectDoesNotExist cases

251-265: LGTM! Unit test for ObjectDoesNotExist on intermediate attribute access.

Direct testing of Command()._get_field_value with a fake object that raises ObjectDoesNotExist on sub-attribute access properly validates the edge case handling.

openwisp_users/management/commands/export_users.py (4)

12-16: LGTM! Clean normalization of field definitions.

The normalize_field function correctly converts string fields to dict format while preserving dict fields as-is.


40-50: LGTM! Field exclusion logic correctly uses normalized names.

The .copy() prevents mutation of the original config, and exclusion matching on normalize_field(field)["name"] ensures consistent behavior.


52-60: LGTM! Queryset optimization with both select_related and prefetch_related.

The queryset correctly applies both optimization strategies from the configuration.


78-92: LGTM! Clean serialization of related managers.

The serialize_related method correctly handles both single and multiple subfields with appropriate formatting.

Comment thread openwisp_users/management/commands/export_users.py Outdated
Comment thread openwisp_users/management/commands/export_users.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
openwisp_users/management/commands/export_users.py (2)

74-75: ⚠️ Potential issue | 🟡 Minor

Wrap the new command messages with gettext.

Both the success message and the callable-failure message are user-facing, but they are still hard-coded English strings. Please mark them as translatable before passing them to SUCCESS / CommandError.

Proposed fix
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.management.base import BaseCommand, CommandError
+from django.utils.translation import gettext as _
@@
-                raise CommandError(f"Error calling function for field '{name}': {e}")
+                raise CommandError(
+                    _("Error calling function for field '%(field)s': %(error)s")
+                    % {"field": name, "error": e}
+                ) from e
@@
         self.stdout.write(
-            self.style.SUCCESS(f"User data exported successfully to {filename}!")
+            self.style.SUCCESS(
+                _("User data exported successfully to %(filename)s!")
+                % {"filename": filename}
+            )
         )

As per coding guidelines: "For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)".

Also applies to: 121-122

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 74 - 75,
Wrap all user-facing hard-coded strings in the command with Django's i18n
gettext: import gettext as _ from django.utils.translation at the top, then
change the success call to self.stdout.write(self.style.SUCCESS(_("User data
exported successfully to %s!") % filename)) and wrap the callable-failure
message passed to CommandError similarly (also update the message used around
the callable failure at lines 121-122). Locate these strings in the export users
management command (the self.stdout.write/self.style.SUCCESS usage and the
CommandError raise) and replace the raw English literals with _("...") so they
become translatable.

104-109: ⚠️ Potential issue | 🟠 Major

Don’t call .iterator() on the related-manager traversal path.

current here can already be the prefetched reverse relation. Django’s related managers return that prefetched queryset from get_queryset(), and QuerySet.iterator() on an already evaluated queryset forces a fresh evaluation. That means the dot-notation manager branch can still re-query per user and miss the PR’s prefetch_related optimization goal. Iterating current.all() (or just current) preserves the prefetched cache instead. (docs.djangoproject.com)

Proposed fix
             if hasattr(current, "iterator") and i < len(parts) - 1:
                 remaining_path = ".".join(parts[i + 1 :])
                 return ",".join(
                     str(self._get_nested_attr(item, remaining_path))
-                    for item in current.iterator()
+                    for item in current.all()
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_users/management/commands/export_users.py` around lines 104 - 109,
The code in _get_nested_attr uses current.iterator(), which can re-evaluate an
already-prefetched related-manager and break prefetch optimization; change the
traversal so it iterates over the relationship without forcing a new iterator
(e.g. replace current.iterator() with current.all() or simply iterate over
current when it's already an iterable/prefetched result) in export_users.py
inside _get_nested_attr's dot-notation branch so the prefetched cache is
preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 74-75: Wrap all user-facing hard-coded strings in the command with
Django's i18n gettext: import gettext as _ from django.utils.translation at the
top, then change the success call to
self.stdout.write(self.style.SUCCESS(_("User data exported successfully to %s!")
% filename)) and wrap the callable-failure message passed to CommandError
similarly (also update the message used around the callable failure at lines
121-122). Locate these strings in the export users management command (the
self.stdout.write/self.style.SUCCESS usage and the CommandError raise) and
replace the raw English literals with _("...") so they become translatable.
- Around line 104-109: The code in _get_nested_attr uses current.iterator(),
which can re-evaluate an already-prefetched related-manager and break prefetch
optimization; change the traversal so it iterates over the relationship without
forcing a new iterator (e.g. replace current.iterator() with current.all() or
simply iterate over current when it's already an iterable/prefetched result) in
export_users.py inside _get_nested_attr's dot-notation branch so the prefetched
cache is preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f243117d-8bba-43f3-907b-b112f9e6e910

📥 Commits

Reviewing files that changed from the base of the PR and between 852e204 and c7b6326.

📒 Files selected for processing (2)
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/tests/test_commands.py
  • openwisp_users/management/commands/export_users.py
🧠 Learnings (2)
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior

Applied to files:

  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Applies to **/*.py : For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Applied to files:

  • openwisp_users/management/commands/export_users.py

@pandafy
Copy link
Copy Markdown
Member Author

pandafy commented Apr 14, 2026

@coderabbitai Review again, focus on security, bugs, performance, unused code or outdated comments/docs

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cat openwisp_users/management/commands/export_users.py
echo "---SETTINGS---"
cat openwisp_users/settings.py
echo "---TESTS---"
cat openwisp_users/tests/test_commands.py

Length of output: 18909


@pandafy Here's a focused review covering security, bugs, performance, and outdated comments/docs:


🔴 Bug — "None" literal can appear in CSV output

File: openwisp_users/management/commands/export_users.py_get_field_value

In the non-manager subfields branch, _get_nested_attr can return None (on AttributeError/ObjectDoesNotExist), but it is passed directly to str(), producing the literal "None" in the CSV:

# current – produces "None" string in CSV when attr is missing
return ",".join(str(self._get_nested_attr(attr, f)) for f in subfields)

serialize_related correctly guards against this ("" if val is None else str(val)), but this branch does not. Fix:

return ",".join(
    "" if (v := self._get_nested_attr(attr, f)) is None else str(v)
    for f in subfields
)

🔴 Bug — Stale test comment

File: openwisp_users/tests/test_commands.pytest_related_fields

# 3 user and 1 header    ← stale copy-paste from test_export_users
self.assertEqual(len(csv_data), 2)

Only one user is created in this test, so the correct comment is # 1 user and 1 header.


🟡 Bug — Test docstring contradicts assertion

File: openwisp_users/tests/test_commands.pytest_dot_notation_objectdoesnotexist_on_sub_attr

def test_dot_notation_objectdoesnotexist_on_sub_attr(self):
    """Returns empty string when sub-attribute access raises ObjectDoesNotExist."""
    ...
    result = Command()._get_field_value(FakeUser(), "intermediate.sub_field")
    self.assertEqual(result, None)  # ← asserts None, not ""

_get_field_value for a plain name field returns whatever _get_nested_attr returns (None on error). The handle() loop converts None"" before writing, but the unit test bypasses that. The docstring says "Returns empty string" while the assertion is None. Either fix the docstring to say "Returns None" or test the CSV output level instead.


🟡 Performance — iterator() + prefetch_related interaction

File: openwisp_users/management/commands/export_users.pyhandle

for user in queryset.iterator(chunk_size=1000):

In Django versions prior to 4.1, iterator() fully bypasses the prefetch cache, making prefetch_related ineffective and causing N+1 queries per chunk. Django 4.1+ evaluates prefetches per chunk, but the code contains no minimum-version guard or comment to make this contract explicit. The inline comment in serialize_related already explains why .all() must be used; a similar note here would prevent future regressions.

# NOTE: iterator() with prefetch_related is only effective (per-chunk) on Django ≥ 4.1.
# On earlier versions prefetch_related has no effect here.
for user in queryset.iterator(chunk_size=1000):

🟡 Security — CSV formula/injection risk

File: openwisp_users/management/commands/export_users.pyhandle

User-supplied values (e.g., username, first_name, email, notes) are written to the CSV without sanitization. Values that start with =, +, -, or @ will be interpreted as formulas by spreadsheet applications (Excel, LibreOffice Calc). This is a well-known CSV injection vector. Consider prefixing such values with a tab character or wrapping them in a safe format.


🟡 Security — Password hash exported by default

File: openwisp_users/settings.py

"fields": [
    "id", "username", "email", "password",   # ← hashed password
    ...
]

The password field (Django's hashed password) is included in the default export. While it is hashed, including it increases the attack surface if the export file is accidentally shared or stored insecurely. Consider removing it from the default config and documenting it as an opt-in field.


🟡 Overly broad manager detection

File: openwisp_users/management/commands/export_users.py_get_nested_attr

if (isinstance(current, QuerySet) or hasattr(current, "all")) and i < len(parts) - 1:

hasattr(current, "all") matches any object that has an all attribute, not just Django managers/QuerySets. A plain Python object with an all attribute (e.g., a custom model property returning a list subclass) would be incorrectly treated as a manager. A more targeted check would be:

from django.db.models.manager import BaseManager
if (isinstance(current, (QuerySet, BaseManager))) and i < len(parts) - 1:

🟢 Minor — No cleanup on write error

File: openwisp_users/management/commands/export_users.pyhandle

If a CommandError is raised mid-write (e.g., from a bad callable), the partially written CSV file is left on disk. A try/except or a tempfile-based approach with atomic rename would be cleaner, though this is a minor operational concern.


🧠 Learnings used
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Flag potential security vulnerabilities in all code

@pandafy
Copy link
Copy Markdown
Member Author

pandafy commented Apr 14, 2026

Performance — iterator() + prefetch_related interaction

File: openwisp_users/management/commands/export_users.pyhandle

for user in queryset.iterator(chunk_size=1000):

In Django versions prior to 4.1, iterator() fully bypasses the prefetch cache, making prefetch_related ineffective and causing N+1 queries per chunk. Django 4.1+ evaluates prefetches per chunk, but the code contains no minimum-version guard or comment to make this contract explicit. The inline comment in serialize_related already explains why .all() must be used; a similar note here would prevent future regressions.

# NOTE: iterator() with prefetch_related is only effective (per-chunk) on Django ≥ 4.1.
# On earlier versions prefetch_related has no effe

We only support Django 4.2+ @coderabbitai

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

Tip

For best results, initiate chat on the files or code changes.

@pandafy Thanks for the clarification! Since Django 4.2+ already evaluates prefetch_related per chunk when using iterator(), the concern is fully addressed by the project's minimum version requirement. The comment suggestion was unnecessary — withdrawing that point.

(̶ ̶•̶ ̶_̶•̶)̶ → (•‿•) ✓


✏️ Learnings added
Learnt from: pandafy
URL: https://github.com/openwisp/openwisp-users/pull/498

Timestamp: 2026-04-14T16:56:52.903Z
Learning: The openwisp/openwisp-users project supports Django 4.2+ as the minimum Django version. Concerns about Django versions prior to 4.1 (e.g., iterator() bypassing prefetch_related cache) are not applicable to this project.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@pandafy
Copy link
Copy Markdown
Member Author

pandafy commented Apr 14, 2026

@coderabbitai Review again, focus on security, bugs, performance, unused code or outdated comments/docs

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== export_users.py ==="
cat openwisp_users/management/commands/export_users.py
echo ""
echo "=== settings.py ==="
cat openwisp_users/settings.py
echo ""
echo "=== test_commands.py ==="
cat openwisp_users/tests/test_commands.py
echo ""
echo "=== docs/user/settings.rst (relevant section) ==="
grep -n "EXPORT_USERS" docs/user/settings.rst | head -20
grep -n "prefetch_related\|select_related\|callable\|fields\|normalize\|serialize" docs/user/settings.rst | head -40

Length of output: 20517


@pandafy Here is the focused re-review covering bugs, security, performance, and code quality that remain:


🔴 Bug — Manager/QuerySet at end of dot-notation path gets stringified

File: export_users.py_get_nested_attr / _get_field_value

The mid-path manager branch only fires when i < len(parts) - 1. If the final resolved attribute is a QuerySet or BaseManager (e.g. "openwisp_users_organizationuser" used as a plain string field with no subfields), control falls through to:

val = self._get_nested_attr(user, name)   # returns the manager object
return self._normalize_value(val)          # → str(manager) → ugly repr in CSV

The subfields branch correctly calls serialize_related, but the plain-name branch does not. A guard keeps the output clean:

# in _get_field_value, plain-name branch
val = self._get_nested_attr(user, name)
if isinstance(val, (QuerySet, BaseManager)):
    # caller should use {"name": ..., "fields": [...]} for relations
    return ""
return self._normalize_value(val)

🔴 Bug — CommandError raised by a callable is double-wrapped

File: export_users.py_get_field_value

except Exception as e:
    raise CommandError(
        _("Error calling function {func_name!r} for field '{name}': {e}").format(...)
    )

CommandError is a subclass of Exception, so if a callable deliberately raises CommandError, it is caught here and wrapped in a second CommandError, replacing the original message. Excluding it keeps the intent clear:

except CommandError:
    raise
except Exception as e:
    ...

🟡 Security — CSV formula-injection risk (still open from previous round)

File: export_users.pyhandle

User-controlled fields (username, email, first_name, notes, etc.) are written to the CSV without sanitization. Values starting with =, +, -, or @ are interpreted as formulas by spreadsheet applications. This was raised in the prior round and has not yet been addressed.


🟡 Security — Hashed password exported by default (still open from previous round)

File: settings.py

"password" (Django's hashed password string) remains in the default fields list. Though hashed, exporting it increases risk if the CSV is accidentally shared. Consider opting it out of the default and documenting it as an explicit opt-in field.


🟡 Performance — organizations_dict not covered by prefetch_related

File: settings.py_export_organizations / EXPORT_USERS_COMMAND_CONFIG

_export_organizations iterates user.organizations_dict. If organizations_dict resolves via a DB lookup (rather than a cached attribute), the default "prefetch_related": [] means every user export triggers an extra query, causing N+1 at scale. Either add the relevant relation to the default prefetch_related list, or add a comment explaining why it is not needed (e.g., if organizations_dict is always warm from a signal/cache):

# _export_organizations reads user.organizations_dict which is populated
# by <explain mechanism>; no prefetch_related entry is required.

🟢 Code quality — _normalize_value does not use self

File: export_users.py

def _normalize_value(self, value):
    return "" if value is None else str(value)

self is never referenced. Marking it @staticmethod (or extracting it as a module-level helper alongside normalize_field) avoids the misleading instance-method signature and allows calling it without a Command instance (as needed in test_dot_notation_objectdoesnotexist_on_sub_attr which calls Command() only for this purpose).


🟢 Test — Dead or "" guard

File: test_commands.pytest_callable_error_handling

self.assertIn("Error calling function '", str(context.exception) or "")

str(...) always returns a str, so or "" can never change the value. It can be simplified to:

self.assertIn("Error calling function '", str(context.exception))

🧠 Learnings used
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T16:56:52.918Z
Learning: The openwisp/openwisp-users project supports Django 4.2+ as the minimum Django version. Concerns about Django versions prior to 4.1 (e.g., iterator() bypassing prefetch_related cache) are not applicable to this project.

Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Flag potential security vulnerabilities in all code

@pandafy
Copy link
Copy Markdown
Member Author

pandafy commented Apr 14, 2026

🟡 Security — CSV formula-injection risk (still open from previous round)

@coderabbitai the user will need to modify the setting in Django project which can be only done by system administrator.

🟡 Security — Hashed password exported by default (still open from previous round)

This was already present in the repository before this change.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

Tip

For best results, initiate chat on the files or code changes.

@pandafy Both points are well taken — withdrawing those concerns:

  1. CSV formula-injection: Acknowledged. Since export_users is a Django management command that can only be invoked and configured by a system administrator, the attack surface is fundamentally different from user-facing input. The concern is not applicable here.

  2. Hashed password in default export: Agreed — this is a pre-existing configuration unrelated to this PR's changes and is out of scope for this review.

(•‿•) ✓


✏️ Learnings added
Learnt from: pandafy
URL: https://github.com/openwisp/openwisp-users/pull/498

Timestamp: 2026-04-14T18:12:00.822Z
Learning: In openwisp/openwisp-users, the `export_users` management command (and its configuration via `OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG`) can only be invoked and modified by a Django system administrator. CSV formula-injection concerns raised against its output are not applicable because the command is not user-facing.

Learnt from: pandafy
URL: https://github.com/openwisp/openwisp-users/pull/498

Timestamp: 2026-04-14T18:12:00.822Z
Learning: In openwisp/openwisp-users, the `password` field (Django hashed password) has been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` since before PR `#498`. Flagging it as a security concern in the context of this PR is out of scope as it is a pre-existing configuration choice.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

Tip

For best results, initiate chat on the files or code changes.

@pandafy Acknowledged on both points — withdrawing both concerns:

  1. CSV formula-injection: Since the export_users command is a Django management command, it can only be invoked by system administrators with server access, not by end users. The attack surface is therefore extremely limited. Withdrawing this concern.

  2. Hashed password in default export fields: Since this was already present in the repository before this PR, it is out of scope here. Withdrawing this concern.


✏️ Learnings added
Learnt from: pandafy
URL: https://github.com/openwisp/openwisp-users/pull/498

Timestamp: 2026-04-14T18:12:13.491Z
Learning: In openwisp/openwisp-users, the `export_users` management command is only accessible to system administrators with server access. CSV formula-injection concerns are not applicable to this command since it is not exposed to untrusted end users.

Learnt from: pandafy
URL: https://github.com/openwisp/openwisp-users/pull/498

Timestamp: 2026-04-14T18:12:13.491Z
Learning: In openwisp/openwisp-users, the `password` (hashed) field has always been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` list as pre-existing behavior. Do not flag this as a new security concern in PRs that do not introduce this default.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@nemesifier
Copy link
Copy Markdown
Member

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

✅ Actions performed

Full review triggered.

1 similar comment
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openwisp_users/management/commands/export_users.py`:
- Around line 152-161: The current exception handler around callable_fn(user) in
export_users.py catches all exceptions and wraps them into a generic
CommandError; change it so CommandError raised by the custom callable is
re-raised unchanged: call callable_fn(user) inside a try/except that first
excepts CommandError and re-raises it, and only then catches other Exception
instances to wrap with the existing error message (refer to callable_fn, user
and CommandError in this block).

In `@openwisp_users/tests/test_commands.py`:
- Around line 177-194: The test currently only checks that unexpected exceptions
from a callable are wrapped in CommandError; add a new case in
test_callable_error_handling that defines a callable (e.g.,
_broken_command_error_callable) which raises CommandError("original msg"), set
it in the EXPORT_USERS_COMMAND_CONFIG fields (same pattern as the existing
config), invoke call_command("export_users", filename=self.temp_file.name,
stderr=stderr) inside assertRaises(CommandError) and assert that
str(context.exception) contains the original message unchanged (e.g., equals or
contains "original msg") to ensure CommandError raised by the callable is not
rewrapped or mutated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a91cb410-d39d-4bb2-9010-c200c538069c

📥 Commits

Reviewing files that changed from the base of the PR and between 85b0a39 and 3a8ca57.

📒 Files selected for processing (4)
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/settings.py
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
🧠 Learnings (7)
📓 Common learnings
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `password` field (Django hashed password) has been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` since before PR `#498`. Flagging it as a security concern in the context of this PR is out of scope as it is a pre-existing configuration choice.
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `password` (hashed) field has always been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` list as pre-existing behavior. Do not flag this as a new security concern in PRs that do not introduce this default.
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `export_users` management command (and its configuration via `OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG`) can only be invoked and modified by a Django system administrator. CSV formula-injection concerns raised against its output are not applicable because the command is not user-facing.
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `export_users` management command is only accessible to system administrators with server access. CSV formula-injection concerns are not applicable to this command since it is not exposed to untrusted end users.
📚 Learning: 2026-04-14T18:12:00.871Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `export_users` management command (and its configuration via `OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG`) can only be invoked and modified by a Django system administrator. CSV formula-injection concerns raised against its output are not applicable because the command is not user-facing.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-04-14T18:12:00.871Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `password` field (Django hashed password) has been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` since before PR `#498`. Flagging it as a security concern in the context of this PR is out of scope as it is a pre-existing configuration choice.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-04-14T18:12:13.520Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `password` (hashed) field has always been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` list as pre-existing behavior. Do not flag this as a new security concern in PRs that do not introduce this default.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-04-14T18:12:13.520Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `export_users` management command is only accessible to system administrators with server access. CSV formula-injection concerns are not applicable to this command since it is not exposed to untrusted end users.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Applies to **/*.py : For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Applied to files:

  • openwisp_users/management/commands/export_users.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior

Applied to files:

  • openwisp_users/tests/test_commands.py

Comment thread openwisp_users/management/commands/export_users.py
Comment thread openwisp_users/tests/test_commands.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/user/settings.rst`:
- Around line 104-107: Add a brief note to the documentation near the default
config block or in the "Field definitions" section clarifying that the shown
value callable: openwisp_users.settings._export_organizations is a reference to
an actual Python callable (function/method) and must be imported into the
settings file rather than used as a string path; mention that users should
import the function and assign it directly in their settings to avoid confusion.

In `@openwisp_users/management/commands/export_users.py`:
- Around line 25-27: The helper _normalize_value does not use instance state, so
make it a static method by adding `@staticmethod` above the def
_normalize_value(self, value) and removing the unused self parameter (rename
signature to _normalize_value(value)); update the implementation to return "" if
value is None else str(value); leave call sites like self._normalize_value(val)
unchanged (they still work) or optionally call Command._normalize_value(val)
where appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2bc25168-49a9-4933-8761-4785df2f6478

📥 Commits

Reviewing files that changed from the base of the PR and between 85b0a39 and 3a8ca57.

📒 Files selected for processing (4)
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/settings.py
  • openwisp_users/tests/test_commands.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (Custom checks)

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Files:

  • openwisp_users/settings.py
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
🧠 Learnings (7)
📓 Common learnings
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `password` field (Django hashed password) has been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` since before PR `#498`. Flagging it as a security concern in the context of this PR is out of scope as it is a pre-existing configuration choice.
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `password` (hashed) field has always been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` list as pre-existing behavior. Do not flag this as a new security concern in PRs that do not introduce this default.
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `export_users` management command (and its configuration via `OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG`) can only be invoked and modified by a Django system administrator. CSV formula-injection concerns raised against its output are not applicable because the command is not user-facing.
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `export_users` management command is only accessible to system administrators with server access. CSV formula-injection concerns are not applicable to this command since it is not exposed to untrusted end users.
📚 Learning: 2026-04-14T18:12:00.871Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `export_users` management command (and its configuration via `OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG`) can only be invoked and modified by a Django system administrator. CSV formula-injection concerns raised against its output are not applicable because the command is not user-facing.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-04-14T18:12:00.871Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:00.871Z
Learning: In openwisp/openwisp-users, the `password` field (Django hashed password) has been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` since before PR `#498`. Flagging it as a security concern in the context of this PR is out of scope as it is a pre-existing configuration choice.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-04-14T18:12:13.520Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `export_users` management command is only accessible to system administrators with server access. CSV formula-injection concerns are not applicable to this command since it is not exposed to untrusted end users.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-04-14T18:12:13.520Z
Learnt from: pandafy
Repo: openwisp/openwisp-users PR: 0
File: :0-0
Timestamp: 2026-04-14T18:12:13.520Z
Learning: In openwisp/openwisp-users, the `password` (hashed) field has always been included in the default `EXPORT_USERS_COMMAND_CONFIG["fields"]` list as pre-existing behavior. Do not flag this as a new security concern in PRs that do not introduce this default.

Applied to files:

  • openwisp_users/settings.py
  • docs/user/settings.rst
  • openwisp_users/management/commands/export_users.py
  • openwisp_users/tests/test_commands.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Applies to **/*.py : For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework (django.utils.translation.gettext, gettext_lazy, or ugettext aliases)

Applied to files:

  • openwisp_users/management/commands/export_users.py
📚 Learning: 2026-03-24T22:22:19.645Z
Learnt from: CR
Repo: openwisp/openwisp-users PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-03-24T22:22:19.645Z
Learning: Changes: Tests must be updated to cover non-trivial changes and ensure proper validation of the modified behavior

Applied to files:

  • openwisp_users/tests/test_commands.py
🔇 Additional comments (9)
openwisp_users/settings.py (1)

18-24: N+1 query concern with _export_organizations is an existing open issue.

The _export_organizations callable reads user.organizations_dict, which performs a per-user DB query (see openwisp_users/base/models.py:128-155). With prefetch_related: [] empty in the default config, the command doesn't benefit from bulk prefetching for this column. This was flagged in a previous review.

Also applies to: 43-43, 46-46

openwisp_users/management/commands/export_users.py (3)

152-161: CommandError raised by callables is still being wrapped.

The current exception handler catches all exceptions including CommandError, so a callable that intentionally raises CommandError with a specific message will have its message obscured by the generic wrapper. This was flagged in a previous review.

💡 Proposed fix to re-raise CommandError unchanged
         if callable_fn is not None:
             try:
                 val = callable_fn(user)
+            except CommandError:
+                raise
             except Exception as e:
                 func_name = getattr(callable_fn, "__name__", repr(callable_fn))
                 raise CommandError(
                     _(
                         "Error calling function {func_name!r} for field '{name}': {e}"
                     ).format(func_name=func_name, name=name, e=e)
-                )
+                ) from e
             return self._normalize_value(val)

91-112: LGTM!

The serialize_related method correctly uses manager.all() to leverage the prefetch_related cache, with a clear explanatory comment. The single vs multiple subfield formatting logic is well-implemented.


114-144: LGTM!

The _get_nested_attr method properly handles:

  • ObjectDoesNotExist and AttributeError exceptions
  • QuerySet/BaseManager detection using proper type checks
  • Recursive resolution for remaining path segments
  • Clear documentation of the return contract
openwisp_users/tests/test_commands.py (4)

177-194: Missing test coverage for callable raising CommandError directly.

This test verifies that generic exceptions are wrapped, but there's no test confirming that a CommandError raised by the callable is preserved unchanged (or currently wrapped - which would be a regression once the fix in export_users.py is applied). Adding this test would validate the expected behavior.

🧪 Suggested additional test case
def test_callable_command_error_passthrough(self):
    """CommandError raised by callable should not be re-wrapped."""
    def _command_error_callable(user):
        raise CommandError("original error message")

    config = {
        "fields": ["id", {"name": "broken", "callable": _command_error_callable}],
        "select_related": [],
        "prefetch_related": [],
    }
    self._create_user()
    with (
        patch.object(app_settings, "EXPORT_USERS_COMMAND_CONFIG", config),
        self.assertRaises(CommandError) as context,
    ):
        call_command("export_users", filename=self.temp_file.name)
    # Original message should be preserved, not wrapped
    self.assertEqual(str(context.exception), "original error message")

26-81: LGTM!

Good test coverage including:

  • Non-ASCII character handling (UTF-8 validation)
  • Explicit header assertion independent of production code
  • Organizations callable formatting validation
  • Query count assertion

196-251: LGTM!

Comprehensive test coverage for the dict-defined field extraction:

  • Single related object with subfields
  • Nullable fields returning empty string
  • Manager with single/multiple subfields
  • Dot-notation on managers
  • ObjectDoesNotExist and empty manager edge cases

253-274: LGTM!

Good unit tests for edge cases:

  • test_dot_notation_objectdoesnotexist_on_sub_attr: Validates empty string return when intermediate property raises ObjectDoesNotExist
  • test_plain_relation_field_returns_empty_string: Confirms manager fields without subfield config return empty string
docs/user/settings.rst (1)

117-198: LGTM!

Excellent documentation covering:

  • Field definition formats (string vs dict)
  • Supported dict keys with clear explanations
  • Priority order for value resolution
  • Related object export methods (dot notation and structured extraction)
  • Query optimization guidance with practical example

Comment thread docs/user/settings.rst
Comment thread openwisp_users/management/commands/export_users.py Outdated
pandafy added 9 commits April 24, 2026 15:15
…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
@pandafy pandafy force-pushed the issues/497-export-users branch from 3a8ca57 to 5453a2c Compare April 24, 2026 10:07
Copy link
Copy Markdown
Member

@nemesifier nemesifier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG is not actually overridable — defeats the purpose of the issue

openwisp_users/settings.py:27-47 defines EXPORT_USERS_COMMAND_CONFIG as a hardcoded module-level dict, with no getattr(settings, "OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG", ...) shim. Every other setting in the same file uses that pattern, but this one does not.

Verified empirically: setting OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG in Django settings has zero effect — app_settings.EXPORT_USERS_COMMAND_CONFIG always returns the hardcoded default.

This contradicts what the documentation tells users to do:

  • docs/user/settings.rst:106-137 documents OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG as the user-facing setting (with type, default, etc.).
  • docs/user/management-commands.rst:25-26 directs users to that setting "for advanced customizations (e.g., adding fields for export)".

This is the primary motivation of issue #497 ("provide generic mechanisms or extension points to support exporting custom fields from objects linked through ForeignKeys" — so that openwisp-radius can extend the export with its RegisteredUser fields). The PR makes the dict shape more flexible but never makes it settable. Downstream apps still have to monkey-patch app_settings.EXPORT_USERS_COMMAND_CONFIG at import time to add fields, which is exactly the brittle workaround the issue was filed to remove.

Note: the missing getattr shim pre-dates this PR (master already had the hardcoded dict), but the docs already advertised it as a Django setting, so the bug was effectively imported and not fixed by a PR whose purpose is extensibility.

Fix: replace the hardcoded assignment with the standard pattern, e.g.

EXPORT_USERS_COMMAND_CONFIG = getattr(
    settings,
    "OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG",
    {
        "fields": [...],
        "select_related": [],
        "prefetch_related": [],
    },
)

A test asserting that the Django setting is honored should be added (currently no test reads the setting through Django's settings layer; all tests patch.object(app_settings, ...), which silently masks this bug).

Tests do not exercise the documented configuration mechanism

All tests configure the command via patch.object(app_settings, "EXPORT_USERS_COMMAND_CONFIG", …) (tests/test_commands.py:117-125, 151-159, 191, 215, 223-249). None use override_settings(OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG=…), which is the path real users would take. This is what allowed the issue above to slip through. At least one happy-path test should drive the command through Django's settings layer end-to-end.

Backward-incompatible change to the organizations column delimiter

The previous behavior (master, export_users.py:66-70) joined organizations with "\n":

data_row.append("\n".join(organizations))

producing (1,True)\n(2,False) inside the cell.

The new _export_organizations callable (openwisp_users/settings.py:18-24) joins with ",":

return ",".join(f'({org_id},{perm["is_admin"]})' for org_id, perm in user.organizations_dict.items())

producing (1,True),(2,False).

Test test_export_users (tests/test_commands.py:77) asserts the new format, so the change is intentional — but it is not called out in the PR description, and any consumer parsing the previous newline-delimited format will break silently on upgrade. This deserves a release-note entry, or alternatively the original "\n" delimiter should be preserved for the default field to keep the output stable.

The default organizations field doesn't use the new prefetch_related infrastructure

_export_organizations reads user.organizations_dict, which (base/models.py:128-154) issues one query per user when its per-user cache is cold. This is not new behavior — the original elif field == "organizations": branch on master had the same characteristic — but the PR description claims "Added support for prefetch_related to optimize queries and avoid N+1 issues", and the default organizations column is not migrated to use that mechanism.

The N+1 is invisible in the test suite because _create_org_user triggers the post-save signal at apps.py:189-196 which calls user.organizations_dict and warms the cache before the export runs. On a real export with a cold cache (process restart, cache eviction, two-day TTL expiry), exporting N users issues N+1 queries for the organizations column.

Either re-implement the default organizations field on top of prefetch_related over openwisp_users_organizationuser so it benefits from the new infrastructure, or note the cold-cache cost in the docs.

Documentation references a private callable as the "default"

docs/user/settings.rst:131 shows openwisp_users.settings._export_organizations as the default value for the organizations field's callable. The leading underscore marks it as private and not part of the public API, but the documentation table presents it as if users could rely on the symbol. Either rename it to a public name (e.g. export_organizations) and document it as a reusable helper, or replace the inline default in the docs with a description like "built-in callable" so an underscored name does not leak into user-facing docs.

serialize_related output format is undocumented and ambiguous in one case

export_users.py:92-113 produces three different shapes from the same code path depending on input:

  • empty manager → ""
  • single subfield, N rows → v1,v2,v3 (no delimiters)
  • multiple subfields, N rows → ((v1,v2),(v3,v4)) (parenthesised tuples)

In addition, _get_field_value lines 173-175 produce a fourth shape for a non-manager attribute with multiple subfields: v1,v2 (comma-joined, no parens). This last case is not covered by tests and shares the same shape as "single subfield, multiple rows", so a CSV consumer cannot tell them apart from the data alone.

docs/user/settings.rst:182-203 describes the fields feature but never specifies the output shapes. Worth either documenting all four variants explicitly or normalizing to a single self-describing shape (e.g. always ((..),(..))).

Minor: unnecessary .copy() on the fields list

export_users.py:48 calls .copy() on EXPORT_USERS_COMMAND_CONFIG.get("fields", []). The bound raw_fields is only iterated (line 56) — the new list fields on line 54 is built fresh from a comprehension, and nothing mutates raw_fields. The copy is defensive but unused; can be removed to keep the line straightforward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

[feature] Support extensible export of ForeignKey relations in export_users management command

3 participants