Skip to content

[ENG-445] refact: user change password py to pydantic#3667

Open
nandkishorr wants to merge 11 commits into
developfrom
ENG-445-move-care-users-api-viewsets-change-password-py-to-pydantic
Open

[ENG-445] refact: user change password py to pydantic#3667
nandkishorr wants to merge 11 commits into
developfrom
ENG-445-move-care-users-api-viewsets-change-password-py-to-pydantic

Conversation

@nandkishorr

@nandkishorr nandkishorr commented May 29, 2026

Copy link
Copy Markdown
Contributor

Proposed Changes

  • Moved the serializer to pydantic.

Associated Issue

Merge Checklist

  • Tests added/fixed
  • Update docs in /docs
  • Linting Complete
  • Any other necessary step

Only 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

    • Password change now trims leading/trailing whitespace, properly verifies the old password, enforces password-strength validators, and returns consistent error responses for failures.
  • Tests

    • Reorganized and expanded password-change tests to cover success, wrong-old, weak/invalid new passwords, and whitespace-handling; removed duplicate whitespace tests from another test module.

@nandkishorr nandkishorr self-assigned this May 29, 2026
@nandkishorr nandkishorr requested a review from a team as a code owner May 29, 2026 11:36
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

Replaces 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.

Changes

Password Change Validation Refactor

Layer / File(s) Summary
Request model and whitespace stripping
care/users/api/viewsets/change_password.py
Adds ChangePasswordSpec Pydantic model with old_password and new_password and validators that strip whitespace and perform password checks.
OpenAPI schema & exception handler
care/users/api/viewsets/change_password.py
Updates put/patch request body schema to reference ChangePasswordSpec and overrides get_exception_handler to return emr_exception_handler.
Endpoint implementation and validation
care/users/api/viewsets/change_password.py
ChangePasswordView.update() now uses ChangePasswordSpec.model_validate(..., context={"user": request.user}), relies on the exception handler for validation errors, sets the new password, and saves the user.
Test suite updates and new whitespace tests
care/users/tests/test_change_password.py
Rewrites tests to inherit CareAPITestBase, centralizes payload in setUp, updates success/weak/wrong-old-password tests, adds invalid-password rejection test, and adds three tests ensuring whitespace in old_password is stripped and accepted.
Removed duplicate tests and minor formatting
care/emr/tests/test_reset_password_api.py
Removes three whitespace-handling tests from this module and adjusts client.post(...) formatting in one unrelated test.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly Related PRs

  • ohcnetwork/care#3669: Overlaps on error-response structure and handling for wrong old_password in the change-password endpoint.
  • ohcnetwork/care#3434: Both PRs update the password change flow to enforce Django password validation and adjust tests for weak/invalid passwords and wrong-old-password scenarios.

Suggested labels

waiting-for-review

Suggested Reviewers

  • vigneshhari
  • rithviknishad
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description is incomplete. While it mentions the main change and links the associated issue, it lacks details about what was actually changed, why the refactoring was necessary, and the architectural implications of moving from DRF serializers to pydantic. Expand the description to include specific changes made, benefits of the pydantic migration, and note the test updates and moved whitespace-handling tests for clarity.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main refactoring work: moving the change password serializer implementation from DRF to pydantic, which aligns with the core changes in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 ENG-445-move-care-users-api-viewsets-change-password-py-to-pydantic

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.

❤️ Share

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

@greptile-apps

greptile-apps Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the DRF ChangePasswordSerializer with a Pydantic ChangePasswordSpec model and wires in emr_exception_handler so that Pydantic ValidationErrors are returned as structured 400 responses instead of 500s. Password whitespace-stripping and old-password verification are moved into the Pydantic model validators, and the test suite is reorganised under CareAPITestBase.

  • Core change (change_password.py): Pydantic model handles field stripping, old-password verification, and new-password strength validation; get_exception_handler now returns emr_exception_handler for correct error serialisation.
  • Tests (test_change_password.py): New tests cover success, wrong-old-password, weak-new-password, and leading/trailing whitespace; duplicate whitespace tests removed from test_reset_password_api.py.
  • Error message format: When validate_password raises a DjangoValidationError with multiple messages, raise ValueError(e.messages) passes a Python list as the error payload, which Pydantic serialises as a stringified list (e.g. "Value error, ['msg1', 'msg2']"); using ", ".join(e.messages) would produce a cleaner string.

Confidence Score: 4/5

Safe 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

Filename Overview
care/users/api/viewsets/change_password.py Migrates change-password logic from DRF serialiser to Pydantic; exception handling is now correctly delegated to emr_exception_handler, but password-validation errors are serialised as a stringified Python list rather than structured messages.
care/users/tests/test_change_password.py Expanded test suite covering success, wrong-old-password, weak-password, and whitespace cases; test_change_password_invalid_password's final assertion is tautological (noted in prior review thread).
care/emr/tests/test_reset_password_api.py Removes three now-duplicate whitespace tests that were moved to test_change_password.py; no logic changes.

Reviews (2): Last reviewed commit: "Merge branch 'develop' into ENG-445-move..." | Re-trigger Greptile

Comment thread care/users/api/viewsets/change_password.py Outdated
Comment thread care/users/api/viewsets/change_password.py Outdated
Comment thread care/users/api/viewsets/change_password.py
Comment thread care/users/tests/test_change_password.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between ece71a8 and c35a9e3.

📒 Files selected for processing (3)
  • care/users/api/viewsets/change_password.py
  • care/users/tests/__init__.py
  • care/users/tests/test_change_password.py

Comment thread care/users/api/viewsets/change_password.py
Comment thread care/users/api/viewsets/change_password.py Outdated
Comment thread care/users/tests/test_change_password.py
Comment thread care/users/tests/test_change_password.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
care/users/tests/test_change_password.py (1)

51-91: ⚡ Quick win

Whitespace coverage only exercises old_password; new_password stripping is left untested.

These three tests thoroughly cover stripping on old_password, which is nice. However, ChangePasswordSpec.strip_passwords also strips new_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 a new_password with 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

📥 Commits

Reviewing files that changed from the base of the PR and between c35a9e3 and 52c1abe.

📒 Files selected for processing (3)
  • care/emr/tests/test_reset_password_api.py
  • care/users/api/viewsets/change_password.py
  • care/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

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.55%. Comparing base (c8baced) to head (8c91598).
⚠️ Report is 2 commits behind head on develop.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread care/users/api/viewsets/change_password.py
@vigneshhari vigneshhari changed the title [Eng-445 ] refact: user change password py to pydantic [Eng-445] refact: user change password py to pydantic May 30, 2026
@nandkishorr nandkishorr requested a review from vigneshhari May 31, 2026 10:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 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

📥 Commits

Reviewing files that changed from the base of the PR and between 52c1abe and c6ccecc.

📒 Files selected for processing (2)
  • care/users/api/viewsets/change_password.py
  • care/users/tests/test_change_password.py

Comment thread care/users/api/viewsets/change_password.py
Comment thread care/users/api/viewsets/change_password.py
@vigneshhari vigneshhari changed the title [Eng-445] refact: user change password py to pydantic [ENG-445] refact: user change password py to pydantic Jun 8, 2026
@nandkishorr nandkishorr force-pushed the ENG-445-move-care-users-api-viewsets-change-password-py-to-pydantic branch from c382f93 to 9412d6d Compare June 8, 2026 10:05
Comment on lines +27 to +44
@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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants