[ENG-445] refact: user change password py to pydantic#3667
[ENG-445] refact: user change password py to pydantic#3667nandkishorr wants to merge 11 commits into
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughReplaces DRF serializer flow with a Pydantic ChangePasswordSpec that strips whitespace and validates old/new passwords; updates ChangePasswordView to parse via the spec and return via the configured exception handler; rewrites tests to centralize payloads and add whitespace-handling and invalid-password cases. ChangesPassword Change Validation Refactor
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly Related PRs
Suggested labels
Suggested Reviewers
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR replaces the DRF
Confidence Score: 4/5Safe to merge for core functionality; the error message format for multi-validator password failures is awkward but not broken. The exception-handling gap flagged in a prior review is now closed. The remaining open concern is that raise ValueError(e.messages) passes a list object to ValueError, so Pydantic serialises all password-strength messages as a single stringified list rather than individual structured errors. care/users/api/viewsets/change_password.py — specifically the except DjangoValidationError block in validate_passwords. Important Files Changed
Reviews (2): Last reviewed commit: "Merge branch 'develop' into ENG-445-move..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@care/users/api/viewsets/change_password.py`:
- Around line 18-22: The top-level request=ChangePasswordSpec on
extend_schema_view won't apply to per-operation docs; move the
request=ChangePasswordSpec into each per-method extend_schema call so the PUT
and PATCH operations get the ChangePasswordSpec request body. Update the
extend_schema_view usage so extend_schema(tags=["users"],
request=ChangePasswordSpec) is applied for the put and patch entries (i.e.,
put=extend_schema(tags=["users"], request=ChangePasswordSpec) and
patch=extend_schema(tags=["users"], request=ChangePasswordSpec)), referencing
extend_schema_view, extend_schema, put, patch, and ChangePasswordSpec.
- Line 32: Wrap the construction of ChangePasswordSpec(**request.data) in a
try/except that catches pydantic.ValidationError in the change password view
(change_password.py) and translate it to a DRF 400 response by raising
rest_framework.exceptions.ValidationError (or returning Response with status=400
and the validation details); reference the ChangePasswordSpec symbol and the
view's handler where request.data is parsed. Also add a new test
test_change_password_missing_fields in care/users/tests/test_change_password.py
that POSTs with missing/invalid old_password or new_password and asserts HTTP
400 plus the returned validation error details.
In `@care/users/tests/test_change_password.py`:
- Around line 44-49: The test test_change_password_invalid_password currently
sets self.payload["new_password"] to the same value as the current password, so
the final assertion self.user.check_password("password123") is meaningless;
change the test to record the current password (or use its known value) as
original_password, set self.payload["new_password"] to a distinct invalid value
(e.g. "invalid_new_pwd"), call self.client.put(self.url, self.payload,
format="json") as before, refresh the user with self.user.refresh_from_db(), and
assert that self.user.check_password(original_password) is still True to prove
the password was not changed.
- Around line 10-15: The test uses hardcoded passwords
(password123/newpassword456) in care/users/tests/test_change_password.py via
create_user_with_password and payload which will trigger Ruff bandit rules
S105/S106; either add "S105,S106" to
tool.ruff.lint.per-file-ignores["**/tests/**"] in ruff.toml so tests are exempt,
or change the test to generate non-literal passwords (e.g., derive them at
runtime via secrets or a test factory) and assign them to the same variables
used (self.create_user_with_password call and
self.payload["old_password"/"new_password"]) to avoid hardcoded string literals.
🪄 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: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 89f9cbab-0607-4c27-bfe2-4ce029b0d1ba
📒 Files selected for processing (3)
care/users/api/viewsets/change_password.pycare/users/tests/__init__.pycare/users/tests/test_change_password.py
There was a problem hiding this comment.
🧹 Nitpick comments (1)
care/users/tests/test_change_password.py (1)
51-91: ⚡ Quick winWhitespace coverage only exercises
old_password;new_passwordstripping is left untested.These three tests thoroughly cover stripping on
old_password, which is nice. However,ChangePasswordSpec.strip_passwordsalso stripsnew_password— meaning a user submitting"newpassword456 "would silently get a different stored password than they typed. That behavior currently has no test guarding it. Consider adding a case that submits anew_passwordwith surrounding whitespace and asserts the stripped value is what's persisted.💚 Suggested additional test
def test_change_password_strips_new_password_whitespace(self): self.payload["new_password"] = " newpassword456 " response = self.client.put(self.url, self.payload, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.user.refresh_from_db() self.assertTrue(self.user.check_password("newpassword456")) self.assertFalse(self.user.check_password(" newpassword456 "))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@care/users/tests/test_change_password.py` around lines 51 - 91, The tests exercise stripping of old_password but miss asserting that new_password is stripped before being stored; add a test in care/users/tests/test_change_password.py (e.g., test_change_password_strips_new_password_whitespace) that sets self.payload["new_password"] to a value with surrounding whitespace, sends the PUT to self.url, asserts a 200 response, refreshes self.user (self.user.refresh_from_db()) and then asserts the stored password matches the stripped value (self.user.check_password("newpassword456")) and not the unstripped value; this verifies ChangePasswordSpec.strip_passwords correctly strips new_password as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@care/users/tests/test_change_password.py`:
- Around line 51-91: The tests exercise stripping of old_password but miss
asserting that new_password is stripped before being stored; add a test in
care/users/tests/test_change_password.py (e.g.,
test_change_password_strips_new_password_whitespace) that sets
self.payload["new_password"] to a value with surrounding whitespace, sends the
PUT to self.url, asserts a 200 response, refreshes self.user
(self.user.refresh_from_db()) and then asserts the stored password matches the
stripped value (self.user.check_password("newpassword456")) and not the
unstripped value; this verifies ChangePasswordSpec.strip_passwords correctly
strips new_password as well.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: d62a9170-ec83-4e85-b589-77ef178d2a0a
📒 Files selected for processing (3)
care/emr/tests/test_reset_password_api.pycare/users/api/viewsets/change_password.pycare/users/tests/test_change_password.py
💤 Files with no reviewable changes (1)
- care/emr/tests/test_reset_password_api.py
🚧 Files skipped from review as they are similar to previous changes (1)
- care/users/api/viewsets/change_password.py
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #3667 +/- ##
========================================
Coverage 79.55% 79.55%
========================================
Files 479 479
Lines 22996 23012 +16
Branches 2378 2380 +2
========================================
+ Hits 18295 18308 +13
- Misses 4096 4099 +3
Partials 605 605 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@care/users/api/viewsets/change_password.py`:
- Around line 27-44: The validator validate_passwords is running after
new_password has been normalized/stripped so the server may silently change the
credential; fix by validating the original, unmodified new password instead of
mutating self.new_password: retrieve the raw submitted value from the
request/validation context (e.g., info.context["raw_new_password"] or similar)
and pass that raw value into validate_password (leave self.new_password
untouched), or alternatively move normalization to happen after
validate_passwords so validate_passwords always sees the exact user-submitted
new_password; ensure no assignment to self.new_password in validate_passwords
and keep references to old_password, new_password, validate_passwords, and
set_password in mind when making the change.
- Around line 65-66: ChangePasswordSpec is too permissive: add Pydantic model
config to forbid unknown fields so
ChangePasswordSpec.model_validate(request.data, context={"user": request.user})
will raise on extra keys. Modify the ChangePasswordSpec class to include
model_config = ConfigDict(extra="forbid") (import ConfigDict from pydantic) so
unexpected request keys are rejected; keep using
ChangePasswordSpec.model_validate(...) where currently called.
🪄 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: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 3fa8b4ef-a714-41bc-a9e4-4f43655f5eee
📒 Files selected for processing (2)
care/users/api/viewsets/change_password.pycare/users/tests/test_change_password.py
c382f93 to
9412d6d
Compare
| @model_validator(mode="after") | ||
| def validate_passwords(self, info: ValidationInfo): | ||
| user = info.context.get("user") | ||
| if not user.check_password(self.old_password): | ||
| msg = "Wrong password entered. Please check your password." | ||
| raise ValueError(msg) | ||
|
|
||
| def validate_new_password(self, value): | ||
| """ | ||
| Validate the new password against Django's password policies. | ||
| """ | ||
| user = self.context["request"].user | ||
| try: | ||
| validate_password(value, user=user) | ||
| validate_password( | ||
| self.new_password, | ||
| user=user, | ||
| password_validators=get_password_validators( | ||
| settings.AUTH_PASSWORD_VALIDATORS | ||
| ), | ||
| ) | ||
| except DjangoValidationError as e: | ||
| raise serializers.ValidationError(e.messages) from e | ||
| return value | ||
| raise ValueError(e.messages) from e | ||
| return self |
There was a problem hiding this comment.
Old-password check and new-password validation are coupled in a single model validator
When old password is correct but new-password validation fails, raise ValueError(e.messages) passes a Python list object as its argument. Pydantic serialises this as "Value error, ['This password is too short.', 'This password is entirely numeric.']" — a stringified list rather than individual structured errors. The assertContains check in the test passes because it only looks for a substring, but any consumer that tries to parse individual validation messages will receive the list syntax in the msg string. Consider raising ValueError(", ".join(e.messages)) to produce a clean string.
Proposed Changes
Associated Issue
Merge Checklist
/docsOnly PR's with test cases included and passing lint and test pipelines will be reviewed
@ohcnetwork/care-backend-maintainers @ohcnetwork/care-backend-admins
Summary by CodeRabbit
Bug Fixes
Tests