openwisp-users 1.3.1__tar.gz → 1.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/CHANGES.rst +78 -0
  2. {openwisp_users-1.3.1/openwisp_users.egg-info → openwisp_users-1.3.3}/PKG-INFO +1 -1
  3. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/__init__.py +1 -1
  4. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/static/openwisp-users/accounts/css/non-admin.css +5 -0
  5. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/admin.py +108 -14
  6. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/permissions.py +27 -1
  7. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/serializers.py +16 -2
  8. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/views.py +17 -8
  9. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/apps.py +20 -4
  10. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/base/models.py +6 -6
  11. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/password_validation.py +2 -1
  12. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tasks.py +5 -2
  13. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_admin.py +636 -0
  14. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_api/test_api.py +341 -10
  15. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_models.py +131 -8
  16. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/utils.py +9 -0
  17. {openwisp_users-1.3.1 → openwisp_users-1.3.3/openwisp_users.egg-info}/PKG-INFO +1 -1
  18. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/LICENSE +0 -0
  19. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/MANIFEST.in +0 -0
  20. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/README.rst +0 -0
  21. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/__init__.py +0 -0
  22. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/adapter.py +0 -0
  23. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/locale/de/LC_MESSAGES/django.po +0 -0
  24. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/locale/fur/LC_MESSAGES/django.po +0 -0
  25. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/locale/it/LC_MESSAGES/django.po +0 -0
  26. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/locale/ru/LC_MESSAGES/django.po +0 -0
  27. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/locale/sl/LC_MESSAGES/django.po +0 -0
  28. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/static/openwisp-users/accounts/css/change-password.css +0 -0
  29. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/account_expiration_reminder_message.html +0 -0
  30. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/account_expiration_reminder_message.txt +0 -0
  31. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/account_expired_message.html +0 -0
  32. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/account_expired_message.txt +0 -0
  33. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/base_message.txt +0 -0
  34. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/email_confirmation_message.html +0 -0
  35. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/email_confirmation_message.txt +0 -0
  36. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/email_confirmation_signup_message.html +0 -0
  37. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/password_expiration_message.html +0 -0
  38. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email/password_expiration_message.txt +0 -0
  39. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email_confirm.html +0 -0
  40. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/email_verification_success.html +0 -0
  41. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/login.html +0 -0
  42. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/logout_success.html +0 -0
  43. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/password_change.html +0 -0
  44. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/password_change_success.html +0 -0
  45. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/account/password_not_required.html +0 -0
  46. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/templates/allauth/layouts/base.html +0 -0
  47. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/urls.py +0 -0
  48. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/accounts/views.py +0 -0
  49. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/__init__.py +0 -0
  50. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/authentication.py +0 -0
  51. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/filters.py +0 -0
  52. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/mixins.py +0 -0
  53. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/swagger.py +0 -0
  54. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/throttling.py +0 -0
  55. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/api/urls.py +0 -0
  56. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/auth.py +0 -0
  57. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/backends.py +0 -0
  58. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/base/__init__.py +0 -0
  59. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/base/forms.py +0 -0
  60. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/locale/de/LC_MESSAGES/django.po +0 -0
  61. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/locale/fur/LC_MESSAGES/django.po +0 -0
  62. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/locale/it/LC_MESSAGES/django.po +0 -0
  63. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/locale/ru/LC_MESSAGES/django.po +0 -0
  64. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/locale/sl/LC_MESSAGES/django.po +0 -0
  65. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/management/__init__.py +0 -0
  66. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/management/commands/__init__.py +0 -0
  67. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/management/commands/export_users.py +0 -0
  68. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/middleware.py +0 -0
  69. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0001_initial.py +0 -0
  70. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0002_auto_20180508_2017.py +0 -0
  71. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0003_default_organization.py +0 -0
  72. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0004_default_groups.py +0 -0
  73. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0005_user_phone_number.py +0 -0
  74. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0006_id_email_index_together.py +0 -0
  75. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0007_unique_email.py +0 -0
  76. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0008_update_admins_permissions.py +0 -0
  77. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0009_create_organization_owners.py +0 -0
  78. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0010_allow_admins_change_organization.py +0 -0
  79. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0011_user_first_name_150_max_length.py +0 -0
  80. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0012_user_location_max_length_256.py +0 -0
  81. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0013_user_birth_date.py +0 -0
  82. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0014_user_notes.py +0 -0
  83. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0015_alter_organization_users_alter_organizationuser_user.py +0 -0
  84. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0016_organizationinvitation.py +0 -0
  85. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0017_user_language.py +0 -0
  86. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0018_allow_operator_view_organisation.py +0 -0
  87. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0019_user_password_updated.py +0 -0
  88. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0020_populate_password_updated_field.py +0 -0
  89. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0021_rename_user_id_email_openwisp_us_id_06c07a_idx.py +0 -0
  90. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0022_user_expiration_date.py +0 -0
  91. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0023_add_auth_token_permissions_to_admins.py +0 -0
  92. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0024_apikey.py +0 -0
  93. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0025_alter_organizationuser_is_admin.py +0 -0
  94. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/0026_user_password_based_token.py +0 -0
  95. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/migrations/__init__.py +0 -0
  96. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/mixins.py +0 -0
  97. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/models.py +0 -0
  98. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/multitenancy.py +0 -0
  99. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/settings.py +0 -0
  100. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/static/openwisp-users/css/admin.css +0 -0
  101. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/static/openwisp-users/css/change-password.css +0 -0
  102. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/static/openwisp-users/js/addform.js +0 -0
  103. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/static/openwisp-users/js/org-autocomplete.js +0 -0
  104. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/templates/admin/action_confirmation.html +0 -0
  105. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/templates/admin/login.html +0 -0
  106. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/templates/admin/openwisp_users/action_confirmation.html +0 -0
  107. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/templates/admin/openwisp_users/login.html +0 -0
  108. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/templates/admin/openwisp_users/user/change_form.html +0 -0
  109. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/templates/custom_password_reset_email.html +0 -0
  110. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/templates/custom_password_reset_email.txt +0 -0
  111. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/__init__.py +0 -0
  112. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_accounts.py +0 -0
  113. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_adapter.py +0 -0
  114. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_api/__init__.py +0 -0
  115. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_api/test_authentication.py +0 -0
  116. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_api/test_password_reset.py +0 -0
  117. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_api/test_throttling.py +0 -0
  118. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_api/test_urls.py +0 -0
  119. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_api/test_views.py +0 -0
  120. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_auth.py +0 -0
  121. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_backends.py +0 -0
  122. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_commands.py +0 -0
  123. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/test_middlewares.py +0 -0
  124. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/tests/utils.py +0 -0
  125. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/views.py +0 -0
  126. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users/widgets.py +0 -0
  127. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users.egg-info/SOURCES.txt +0 -0
  128. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users.egg-info/dependency_links.txt +0 -0
  129. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users.egg-info/not-zip-safe +0 -0
  130. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users.egg-info/requires.txt +0 -0
  131. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/openwisp_users.egg-info/top_level.txt +0 -0
  132. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/pyproject.toml +0 -0
  133. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/requirements.txt +0 -0
  134. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/setup.cfg +0 -0
  135. {openwisp_users-1.3.1 → openwisp_users-1.3.3}/setup.py +0 -0
@@ -1,6 +1,84 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ Version 1.3.3 [2026-11-13]
5
+ --------------------------
6
+
7
+ Bugfixes
8
+ ~~~~~~~~
9
+
10
+ - Prevented unauthorized changes to owner memberships
11
+
12
+ Prevent non-superusers from editing owner memberships in
13
+ OrganizationUserAdmin.
14
+
15
+ Superusers can still update owner memberships, and administrators can
16
+ still edit other memberships.
17
+
18
+ **Security Advisory**: `GHSA-7h4r-39v5-q7cq
19
+ <https://github.com/openwisp/openwisp-users/security/advisories/GHSA-7h4r-39v5-q7cq>`_.
20
+
21
+ - Enforced password validators in user password API
22
+
23
+ Apply configured password validators when creating users and changing
24
+ their passwords through the API.
25
+
26
+ **Security Advisory**: `GHSA-qx7x-g6rx-5g3g
27
+ <https://github.com/openwisp/openwisp-users/security/advisories/GHSA-qx7x-g6rx-5g3g>`_.
28
+
29
+ - Aligned organization owner permissions across admin and API
30
+
31
+ Use consistent organization owner permission checks for account,
32
+ password, and email management in the admin and REST API.
33
+
34
+ Include owners of inactive organizations in ownership checks. Existing
35
+ access for superusers and owners managing their own accounts remains
36
+ unchanged.
37
+
38
+ **Security Advisory**: `GHSA-97jh-j6hw-4389
39
+ <https://github.com/openwisp/openwisp-users/security/advisories/GHSA-97jh-j6hw-4389>`_.
40
+
41
+ - Prevented stale memberships after organization status changes
42
+
43
+ Clear cached memberships after the organization status change commits,
44
+ preventing stale memberships from being cached during the transaction.
45
+
46
+ **Security Advisory**: `GHSA-x794-gqv9-rffh
47
+ <https://github.com/openwisp/openwisp-users/security/advisories/GHSA-x794-gqv9-rffh>`_.
48
+
49
+ - Prevented unauthorized changes to organization memberships
50
+
51
+ When adding or editing an organization membership in
52
+ OrganizationUserAdmin, organization admins can only select users who
53
+ already belong to an organization they manage and cannot select
54
+ superusers.
55
+
56
+ **Security Advisory**: `GHSA-p9mp-fxhh-hmfw
57
+ <https://github.com/openwisp/openwisp-users/security/advisories/GHSA-p9mp-fxhh-hmfw>`_.
58
+
59
+ - Improved permission checks for bulk user actions
60
+
61
+ Organization administrators can only activate or deactivate users they
62
+ have permission to edit. If the selection includes a user they cannot
63
+ edit, the action is stopped and a message is displayed.
64
+
65
+ **Security advisory**: `GHSA-q66j-wp8j-5hf5
66
+ <https://github.com/openwisp/openwisp-users/security/advisories/GHSA-q66j-wp8j-5hf5>`_.
67
+
68
+ Version 1.3.2 [2026-10-23]
69
+ --------------------------
70
+
71
+ Bugfixes
72
+ ~~~~~~~~
73
+
74
+ - Restored logo padding on mobile screens
75
+
76
+ Rules in `openwisp.css` removed the logo padding on non-admin screens,
77
+ making it appear cramped.
78
+
79
+ - Corrected password expiration dates and duplicate notifications `#375
80
+ <https://github.com/openwisp/openwisp-users/issues/375>`_
81
+
4
82
  Version 1.3.1 [2026-09-09]
5
83
  --------------------------
6
84
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openwisp-users
3
- Version: 1.3.1
3
+ Version: 1.3.3
4
4
  Summary: Provides basic multi-tenancy features for OpenWISP (using the Django web-framework)
5
5
  Home-page: http://openwisp.org
6
6
  Download-URL: https://github.com/openwisp/openwisp-users/releases
@@ -1,4 +1,4 @@
1
- VERSION = (1, 3, 1, "final")
1
+ VERSION = (1, 3, 3, "final")
2
2
  __version__ = VERSION # alias
3
3
 
4
4
 
@@ -5,3 +5,8 @@ button,
5
5
  form label {
6
6
  margin-right: 5px;
7
7
  }
8
+ @media (max-width: 768px) {
9
+ h1#site-name {
10
+ padding: 0.5rem 0;
11
+ }
12
+ }
@@ -8,19 +8,19 @@ from django.apps import apps
8
8
  from django.conf import settings
9
9
  from django.contrib import admin, messages
10
10
  from django.contrib.admin.actions import delete_selected
11
- from django.contrib.admin.utils import model_ngettext
11
+ from django.contrib.admin.utils import model_ngettext, quote
12
12
  from django.contrib.auth import get_user_model
13
13
  from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
14
14
  from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
15
15
  from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
16
16
  from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
17
- from django.core.exceptions import ValidationError
18
- from django.db.models import Q
17
+ from django.core.exceptions import PermissionDenied, ValidationError
18
+ from django.db.models import OuterRef, Q, Subquery
19
19
  from django.forms.models import BaseInlineFormSet
20
20
  from django.http import HttpResponseRedirect
21
21
  from django.template.response import TemplateResponse
22
22
  from django.urls import reverse
23
- from django.utils.html import format_html
23
+ from django.utils.html import format_html, format_html_join
24
24
  from django.utils.timezone import localdate
25
25
  from django.utils.translation import gettext_lazy as _
26
26
  from django.utils.translation import ngettext
@@ -37,7 +37,7 @@ from openwisp_utils.admin import CopyableFieldsAdmin
37
37
 
38
38
  from . import settings as app_settings
39
39
  from .multitenancy import MultitenantAdminMixin, MultitenantOrgFilter
40
- from .utils import BaseAdmin
40
+ from .utils import BaseAdmin, user_not_allowed_to_change_owner
41
41
 
42
42
  Group = load_model("openwisp_users", "Group")
43
43
  Organization = load_model("openwisp_users", "Organization")
@@ -47,15 +47,6 @@ User = get_user_model()
47
47
  logger = logging.getLogger(__name__)
48
48
 
49
49
 
50
- def user_not_allowed_to_change_owner(user, obj):
51
- return (
52
- obj
53
- and not user.is_superuser
54
- and user.pk != obj.pk
55
- and obj.is_owner_of_any_organization
56
- )
57
-
58
-
59
50
  class EmailAddressInline(admin.StackedInline):
60
51
  model = EmailAddress
61
52
  extra = 0
@@ -237,6 +228,41 @@ class UserAdmin(MultitenantAdminMixin, BaseUserAdmin, BaseAdmin):
237
228
  class Media:
238
229
  css = {"all": ("openwisp-users/css/admin.css",)}
239
230
 
231
+ def _format_permission_denied(self, users, has_more):
232
+ display_names = []
233
+ url_name = f"{self.opts.app_label}_{self.opts.model_name}_change"
234
+ for user in users:
235
+ url = reverse(
236
+ f"{self.admin_site.name}:{url_name}",
237
+ args=[quote(user.pk)],
238
+ )
239
+ display_names.append(format_html('<a href="{}">{}</a>', url, user.username))
240
+ count = len(users)
241
+ if has_more:
242
+ formatted_names = format_html_join(
243
+ ", ", "{}", ((name,) for name in display_names)
244
+ )
245
+ elif count > 1:
246
+ preceding = format_html_join(
247
+ ", ", "{}", ((name,) for name in display_names[:-1])
248
+ )
249
+ formatted_names = format_html(
250
+ "{} {} {}", preceding, _("and"), display_names[-1]
251
+ )
252
+ else:
253
+ formatted_names = display_names[0]
254
+ return format_html(
255
+ ngettext(
256
+ "No users were updated because you do not have permission "
257
+ "to modify this user: {usernames}{ending}",
258
+ "No users were updated because you do not have permission "
259
+ "to modify these users: {usernames}{ending}",
260
+ count,
261
+ ),
262
+ usernames=formatted_names,
263
+ ending=" …" if has_more else ".",
264
+ )
265
+
240
266
  def require_confirmation(func):
241
267
  """
242
268
  Decorator to lead to a confirmation page.
@@ -247,6 +273,40 @@ class UserAdmin(MultitenantAdminMixin, BaseUserAdmin, BaseAdmin):
247
273
  """
248
274
 
249
275
  def wrapper(modeladmin, request, queryset):
276
+ # Bulk updates bypass the object checks used by the change form.
277
+ unauthorized_users = []
278
+ has_more_unauthorized_users = False
279
+ checked_pks = []
280
+ for obj in queryset.iterator(chunk_size=1000):
281
+ if not modeladmin.has_change_permission(request, obj):
282
+ if len(unauthorized_users) < 10:
283
+ unauthorized_users.append(obj)
284
+ else:
285
+ has_more_unauthorized_users = True
286
+ # An eleventh denial confirms truncation. The action cannot
287
+ # proceed, so checking further users would not change it.
288
+ break
289
+ else:
290
+ checked_pks.append(obj.pk)
291
+ if unauthorized_users:
292
+ modeladmin.message_user(
293
+ request,
294
+ modeladmin._format_permission_denied(
295
+ unauthorized_users, has_more_unauthorized_users
296
+ ),
297
+ messages.ERROR,
298
+ )
299
+ return HttpResponseRedirect(request.get_full_path())
300
+ queryset = queryset.filter(pk__in=checked_pks)
301
+ if not request.user.is_superuser:
302
+ protected_owner_ids = (
303
+ OrganizationOwner.objects.filter(
304
+ organization_user__user_id=OuterRef("pk")
305
+ )
306
+ .exclude(organization_user__user_id=request.user.pk)
307
+ .values("organization_user__user_id")
308
+ )
309
+ queryset = queryset.exclude(pk__in=Subquery(protected_owner_ids))
250
310
  opts = modeladmin.model._meta
251
311
  if request.POST.get("confirmation") is None:
252
312
  request.current_app = modeladmin.admin_site.name
@@ -596,6 +656,40 @@ class OrganizationUserAdmin(
596
656
  fields = ("user", "organization", "is_admin")
597
657
  search_fields = ["user__username", "organization__name"]
598
658
 
659
+ def formfield_for_foreignkey(self, db_field, request, **kwargs):
660
+ if db_field.name == "user" and not request.user.is_superuser:
661
+ # Filter only non-superusers belonging to organizations
662
+ # managed by the requester.
663
+ queryset = User.objects.filter(
664
+ id__in=OrganizationUser.objects.filter(
665
+ organization_id__in=request.user.organizations_managed
666
+ ).values_list("user_id"),
667
+ is_superuser=False,
668
+ )
669
+ self._check_user(request, queryset)
670
+ kwargs["queryset"] = queryset
671
+ return super().formfield_for_foreignkey(db_field, request, **kwargs)
672
+
673
+ def _check_user(self, request, queryset):
674
+ user_id = request.POST.get("user")
675
+ if request.method != "POST" or not user_id:
676
+ return
677
+ try:
678
+ # Detect a submitted user ID outside the requester's scope.
679
+ allowed = queryset.filter(pk=user_id).exists()
680
+ except (ValidationError, ValueError, TypeError, OverflowError):
681
+ return
682
+ if not allowed:
683
+ # Django logs this potentially malicious request as a warning.
684
+ raise PermissionDenied(
685
+ f"Rejected unavailable membership user: actor={request.user.pk}"
686
+ )
687
+
688
+ def has_change_permission(self, request, obj=None):
689
+ if obj and not request.user.is_superuser and hasattr(obj, "organizationowner"):
690
+ return False
691
+ return super().has_change_permission(request, obj)
692
+
599
693
  def get_readonly_fields(self, request, obj=None):
600
694
  # retrieve readonly fields
601
695
  fields = super().get_readonly_fields(request, obj)
@@ -1,10 +1,12 @@
1
1
  from django.utils.translation import gettext_lazy as _
2
- from rest_framework.permissions import BasePermission
2
+ from rest_framework.permissions import SAFE_METHODS, BasePermission
3
3
  from rest_framework.permissions import (
4
4
  DjangoModelPermissions as BaseDjangoModelPermissions,
5
5
  )
6
6
  from swapper import load_model
7
7
 
8
+ from ..utils import user_not_allowed_to_change_owner
9
+
8
10
  Organization = load_model("openwisp_users", "Organization")
9
11
 
10
12
 
@@ -95,6 +97,30 @@ class IsOrganizationOwner(BaseOrganizationPermission):
95
97
  return org and (user.is_superuser or user.is_owner(org))
96
98
 
97
99
 
100
+ class CanChangeUser(BasePermission):
101
+ message = _(
102
+ "You do not have permission to edit or delete this user "
103
+ "because they are owner of an organization."
104
+ )
105
+
106
+ def has_object_permission(self, request, view, obj):
107
+ return request.method in SAFE_METHODS or not user_not_allowed_to_change_owner(
108
+ request.user, obj
109
+ )
110
+
111
+
112
+ class CanChangeUserEmail(CanChangeUser):
113
+ def has_permission(self, request, view):
114
+ if request.method in SAFE_METHODS or request.user.is_superuser:
115
+ return True
116
+ return super().has_object_permission(
117
+ request, view, view.get_parent_queryset().first()
118
+ )
119
+
120
+ def has_object_permission(self, request, view, obj):
121
+ return super().has_object_permission(request, view, obj.user)
122
+
123
+
98
124
  class DjangoModelPermissions(ObjectOrganizationMixin, BaseDjangoModelPermissions):
99
125
  perms_map = {
100
126
  "GET": ["%(app_label)s.view_%(model_name)s"],
@@ -9,9 +9,11 @@ from dj_rest_auth.serializers import (
9
9
  PasswordResetSerializer as BasePasswordResetSerializer,
10
10
  )
11
11
  from django.conf import settings
12
- from django.contrib.auth import get_user_model
12
+ from django.contrib.auth import get_user_model, password_validation
13
+ from django.contrib.auth.hashers import check_password
13
14
  from django.contrib.auth.models import Permission
14
15
  from django.contrib.sites.shortcuts import get_current_site
16
+ from django.core.exceptions import ValidationError as DjangoValidationError
15
17
  from django.db import transaction
16
18
  from django.db.models import Q
17
19
  from django.utils.module_loading import import_string
@@ -275,6 +277,17 @@ class SuperUserListSerializer(BaseSuperUserSerializer):
275
277
  raise serializers.ValidationError(_("This field cannot be blank."))
276
278
  return value
277
279
 
280
+ def validate(self, data):
281
+ data = super().validate(data)
282
+ values = dict(data)
283
+ for field in self._skip_validation_fields + ["password"]:
284
+ values.pop(field, None)
285
+ try:
286
+ password_validation.validate_password(data["password"], user=User(**values))
287
+ except DjangoValidationError as error:
288
+ raise serializers.ValidationError({"password": error.messages})
289
+ return data
290
+
278
291
  def create(self, validated_data):
279
292
  group_data = validated_data.pop("groups", None)
280
293
  org_user_data = validated_data.pop("organization_users", None)
@@ -457,7 +470,7 @@ class ChangePasswordSerializer(serializers.Serializer):
457
470
  else:
458
471
  if self.initial_data.get("new_password"):
459
472
  to_change_user = self.context["user"]
460
- if not to_change_user.check_password(value):
473
+ if not check_password(value, to_change_user.password):
461
474
  raise serializers.ValidationError(
462
475
  _(
463
476
  "Your old password was entered incorrectly."
@@ -473,6 +486,7 @@ class ChangePasswordSerializer(serializers.Serializer):
473
486
  raise serializers.ValidationError(
474
487
  _("New password cannot be the same as your old password.")
475
488
  )
489
+ password_validation.validate_password(value, user=self.context["user"])
476
490
  return value
477
491
 
478
492
  def save(self, **kwargs):
@@ -20,7 +20,11 @@ from rest_framework.response import Response
20
20
  from rest_framework.settings import api_settings
21
21
  from swapper import load_model
22
22
 
23
- from openwisp_users.api.permissions import DjangoModelPermissions
23
+ from openwisp_users.api.permissions import (
24
+ CanChangeUser,
25
+ CanChangeUserEmail,
26
+ DjangoModelPermissions,
27
+ )
24
28
  from openwisp_users.auth import create_auth_token
25
29
  from openwisp_users.backends import UsersAuthenticationBackend
26
30
  from openwisp_utils.api.pagination import OpenWispPagination
@@ -150,6 +154,8 @@ class OrganizationDetailView(BaseOrganizationView, RetrieveUpdateDestroyAPIView)
150
154
 
151
155
 
152
156
  class BaseUserView(ProtectedAPIMixin):
157
+ permission_classes = ProtectedAPIMixin.permission_classes + (CanChangeUser,)
158
+
153
159
  def get_queryset(self):
154
160
  user = self.request.user
155
161
  if user.is_superuser:
@@ -221,7 +227,11 @@ class ChangePasswordView(BaseUserView, UpdateAPIView):
221
227
  if self.request.user.id == self.kwargs["pk"]:
222
228
  self.permission_classes = [IsAuthenticated]
223
229
  else:
224
- self.permission_classes = [IsAuthenticated, DjangoModelPermissions]
230
+ self.permission_classes = [
231
+ IsAuthenticated,
232
+ DjangoModelPermissions,
233
+ CanChangeUser,
234
+ ]
225
235
  return super().get_permissions()
226
236
 
227
237
  def get_object(self):
@@ -241,15 +251,13 @@ class ChangePasswordView(BaseUserView, UpdateAPIView):
241
251
  "id": self.kwargs["pk"],
242
252
  }
243
253
  obj = get_object_or_404(qs, **filter_kwargs)
254
+ self.check_object_permissions(self.request, obj)
244
255
  return obj
245
256
 
246
- def get_serializer_context(self):
247
- context = super().get_serializer_context()
248
- context["user"] = self.get_object()
249
- return context
250
-
251
257
  def update(self, request, *args, **kwargs):
252
- serializer = self.get_serializer(data=request.data)
258
+ context = self.get_serializer_context()
259
+ context["user"] = self.get_object()
260
+ serializer = self.get_serializer(data=request.data, context=context)
253
261
  serializer.is_valid(raise_exception=True)
254
262
  serializer.save()
255
263
  return Response(
@@ -271,6 +279,7 @@ class PasswordChangeView(ProtectedAPIMixin, BasePasswordChangeView):
271
279
 
272
280
 
273
281
  class BaseEmailView(ProtectedAPIMixin, FilterByParent, GenericAPIView):
282
+ permission_classes = ProtectedAPIMixin.permission_classes + (CanChangeUserEmail,)
274
283
  model = EmailAddress
275
284
  serializer_class = EmailAddressSerializer
276
285
 
@@ -113,6 +113,11 @@ class OpenwispUsersConfig(AppConfig):
113
113
  sender=Organization,
114
114
  dispatch_uid="handle_org_is_active_change",
115
115
  )
116
+ post_save.connect(
117
+ self.invalidate_org_membership_cache,
118
+ sender=Organization,
119
+ dispatch_uid="invalidate_org_membership_cache",
120
+ )
116
121
 
117
122
  for model in [OrganizationUser, OrganizationOwner]:
118
123
  for signal, name in signal_tuples:
@@ -174,19 +179,30 @@ class OpenwispUsersConfig(AppConfig):
174
179
  record_password_based_login(request, False)
175
180
 
176
181
  @classmethod
177
- def handle_org_is_active_change(cls, instance, **kwargs):
182
+ def handle_org_is_active_change(cls, instance, using, **kwargs):
178
183
  if instance._state.adding:
179
184
  # If it's a new organization, we don't need to update any cache
180
185
  return
181
186
  Organization = instance._meta.model
182
187
  try:
183
- old_instance = Organization.objects.only("is_active").get(pk=instance.pk)
188
+ old_instance = (
189
+ Organization.objects.using(using).only("is_active").get(pk=instance.pk)
190
+ )
184
191
  except Organization.DoesNotExist:
185
192
  return
193
+ instance._is_active_changed = instance.is_active != old_instance.is_active
194
+
195
+ @classmethod
196
+ def invalidate_org_membership_cache(cls, instance, using, **kwargs):
197
+ if not getattr(instance, "_is_active_changed", False):
198
+ return
199
+ instance._is_active_changed = False
186
200
  from .tasks import invalidate_org_membership_cache
187
201
 
188
- if instance.is_active != old_instance.is_active:
189
- invalidate_org_membership_cache.delay(instance.pk)
202
+ pk = instance.pk
203
+ transaction.on_commit(
204
+ lambda: invalidate_org_membership_cache.delay(pk), using=using
205
+ )
190
206
 
191
207
  @classmethod
192
208
  def pre_save_update_organizations_dict(cls, instance, **kwargs):
@@ -129,7 +129,7 @@ class AbstractUser(BaseUser):
129
129
  return str(pk)
130
130
 
131
131
  def set_password(self, *args, **kwargs):
132
- self.password_updated = timezone.now().date()
132
+ self.password_updated = localdate()
133
133
  return super().set_password(*args, **kwargs)
134
134
 
135
135
  def has_password_expired(self):
@@ -145,7 +145,7 @@ class AbstractUser(BaseUser):
145
145
  )
146
146
  else:
147
147
  return False
148
- return expiry_date < timezone.now().date()
148
+ return expiry_date < localdate()
149
149
 
150
150
  def is_member(self, organization):
151
151
  return self._get_pk(organization) in self.organizations_dict
@@ -160,10 +160,10 @@ class AbstractUser(BaseUser):
160
160
 
161
161
  @cached_property
162
162
  def is_owner_of_any_organization(self):
163
- for value in self.organizations_dict.values():
164
- if value["is_owner"]:
165
- return True
166
- return False
163
+ OrganizationOwner = load_model("openwisp_users", "OrganizationOwner")
164
+ # Query ownership directly because organizations_dict excludes inactive
165
+ # organizations, which can still have owners.
166
+ return OrganizationOwner.objects.filter(organization_user__user=self).exists()
167
167
 
168
168
  @property
169
169
  def organizations_dict(self):
@@ -1,3 +1,4 @@
1
+ from django.contrib.auth.hashers import check_password
1
2
  from django.core.exceptions import ValidationError
2
3
  from django.utils.translation import gettext_lazy as _
3
4
 
@@ -11,7 +12,7 @@ class PasswordReuseValidator:
11
12
  def validate(self, password, user=None):
12
13
  if user is None:
13
14
  return
14
- if user.check_password(password):
15
+ if check_password(password, user.password):
15
16
  # The new password is same as the current password
16
17
  raise ValidationError(
17
18
  _("You cannot re-use your current password. Enter a new password.")
@@ -6,7 +6,7 @@ from django.db.models import Q
6
6
  from django.template.loader import render_to_string
7
7
  from django.urls import reverse
8
8
  from django.utils import translation
9
- from django.utils.timezone import now, timedelta
9
+ from django.utils.timezone import localdate, timedelta
10
10
  from django.utils.translation import gettext_lazy as _
11
11
  from swapper import load_model
12
12
 
@@ -30,7 +30,7 @@ def password_expiration_email():
30
30
  ):
31
31
  # The password expiration feature is not enabled
32
32
  return
33
- expiry_date = now().date() + timedelta(days=7)
33
+ expiry_date = localdate() + timedelta(days=7)
34
34
  query = Q()
35
35
  if app_settings.USER_PASSWORD_EXPIRATION:
36
36
  query |= Q(
@@ -54,6 +54,9 @@ def password_expiration_email():
54
54
  emailaddress__verified=True,
55
55
  )
56
56
  .filter(query)
57
+ # a user can own more than one verified email address:
58
+ # without distinct() the join would send one email per address
59
+ .distinct()
57
60
  )
58
61
  email_count = 0
59
62
  for user in qs.iterator():