Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4ec016d
feat:added user otp model
nandkishorr Apr 27, 2026
667e54e
feat:added pydantic specs for apis
nandkishorr Apr 27, 2026
3dfdd03
feat:created send and confirm apis
nandkishorr Apr 27, 2026
8021e7a
feat:added reset password template
nandkishorr Apr 27, 2026
f666b68
feat:added routes and migrations
nandkishorr Apr 27, 2026
520e877
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr Apr 27, 2026
8a9b522
fix:cleanup - review suggestions
nandkishorr Apr 27, 2026
164721e
Merge branch 'ENG-32-create-otp-based-reset-for-password' of https://…
nandkishorr Apr 27, 2026
54cdcda
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr Apr 29, 2026
7454a65
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 5, 2026
e15ae5a
refact:updated the api and otp sending method
nandkishorr May 6, 2026
afeb68c
cleanup
nandkishorr May 6, 2026
fafad78
refact:message contex files to env
nandkishorr May 6, 2026
ab6042d
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 7, 2026
0a9937b
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 17, 2026
fc1da34
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 21, 2026
7215c1e
refact:updated the otp validation to api function
nandkishorr May 21, 2026
9347046
refact:review changes added
nandkishorr May 21, 2026
f532197
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 25, 2026
447ce22
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 26, 2026
6fe3ccd
fix:added review changes
nandkishorr May 26, 2026
97d1998
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 28, 2026
1638abe
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 28, 2026
7f8e3d9
Merge branch 'ENG-32-create-otp-based-reset-for-password' of https://…
nandkishorr May 28, 2026
02e5abc
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 28, 2026
21dc945
Merge branch 'develop' into ENG-32-create-otp-based-reset-for-password
nandkishorr May 28, 2026
acfb02a
feat:added testcases for otp reset password api
nandkishorr May 28, 2026
8e03058
Merge branch 'ENG-32-create-otp-based-reset-for-password' of https://…
nandkishorr May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions care/templates/sms/otp_reset_sms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Your CARE password reset OTP is {{random_otp}}. Do not share this with anyone.
Empty file.
156 changes: 156 additions & 0 deletions care/users/api/otp_viewset/reset_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import logging
import secrets
import string
from datetime import timedelta

from django.conf import settings
from django.contrib.auth.password_validation import (
get_password_validators,
validate_password,
)
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from pydantic import BaseModel, Field, field_validator
from pydantic import ValidationError as PydanticValidationError
from rest_framework.exceptions import ValidationError
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response

from care.users.models import User, UserMobileOTP
from care.utils import sms
from care.utils.models.validators import mobile_validator
from care.utils.sms.utils import get_sms_content
from config.ratelimit import ratelimit

logger = logging.getLogger(__name__)


def rand_pass(size):
if not settings.USE_SMS:
return "45612"

return "".join(secrets.choice(string.digits) for _ in range(size))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


class OTPBaseSpec(BaseModel):
phone_number: str

@field_validator("phone_number")
@classmethod
def validate_phone_number(cls, value):
try:
mobile_validator(value)
except Exception as e:
msg = "Invalid phone number"
raise ValueError(msg) from e
return value
Comment thread
nandkishorr marked this conversation as resolved.
Outdated


class OTPResetSendSpec(OTPBaseSpec):
pass


class OTPResetConfirmSpec(OTPBaseSpec):
otp: str = Field(min_length=settings.OTP_LENGTH, max_length=settings.OTP_LENGTH)
password: str = Field(min_length=8)


class OTPResetSendView(GenericAPIView):
Comment thread
nandkishorr marked this conversation as resolved.
Outdated
authentication_classes = []
permission_classes = []

@extend_schema(request=OTPResetSendSpec)
def post(self, request):
try:
data = OTPResetSendSpec(**request.data)
except PydanticValidationError as e:
raise ValidationError(e.errors()) from e

if ratelimit(request, "otp-password-reset", ["ip"]):
error_message = "Too many requests. Please try again later."
return Response(
{"detail": error_message},
status=429,
)

sent_otps = UserMobileOTP.objects.filter(
created_date__gte=(
timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
),
is_used=False,
phone_number=data.phone_number,
)
if sent_otps.count() >= settings.OTP_MAX_REPEATS_WINDOW:
raise ValidationError(
{"error": "Max OTP requests exceeded. Try again later."}
)
if not User.objects.filter(phone_number=data.phone_number).exists():
return Response({"otp": "generated"})

random_otp = rand_pass(settings.OTP_LENGTH)
if settings.USE_SMS:
try:
content = get_sms_content(
settings.OTP_SMS_RESET_PASSWORD_TEMPLATE_PATH,
{"random_otp": random_otp},
)
sms.send_text_message(
content=content,
recipients=[data.phone_number],
)
except Exception as e:
logger.error(e)
return Response(
{"error": "Error while sending OTP. Contact admin."}, status=400
)

UserMobileOTP.objects.create(phone_number=data.phone_number, otp=random_otp)
return Response({"otp": "generated"})


class OTPResetConfirmView(GenericAPIView):
authentication_classes = []
permission_classes = []

@extend_schema(request=OTPResetConfirmSpec)
def post(self, request):
try:
data = OTPResetConfirmSpec(**request.data)
except PydanticValidationError as e:
raise ValidationError(e.errors()) from e
if ratelimit(request, "otp-password-confirm", ["ip"]):
error_message = "Too many requests. Please try again later."
return Response(
{"detail": error_message},
status=429,
)
user = User.objects.filter(phone_number=data.phone_number).first()
Comment thread
nandkishorr marked this conversation as resolved.
Outdated
if not user:
raise ValidationError({"error": "No User linked to this phone number"})
otp_obj = (
UserMobileOTP.objects.filter(
phone_number=data.phone_number,
is_used=False,
created_date__gte=(
timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
),
)
.order_by("-created_date")
.first()
)
if not otp_obj or otp_obj.otp != data.otp:
raise ValidationError({"otp": "Invalid OTP"})

validate_password(
data.password,
user=user,
password_validators=get_password_validators(
settings.AUTH_PASSWORD_VALIDATORS
),
)
user.set_password(data.password)
user.save()
UserMobileOTP.objects.filter(
phone_number=data.phone_number,
).delete()
Comment thread
nandkishorr marked this conversation as resolved.
return Response({"message": "Password reset successful"})
31 changes: 31 additions & 0 deletions care/users/migrations/0028_usermobileotp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2026-04-27 08:54

import care.utils.models.validators
import uuid
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0027_user_cached_role_orgs'),
]

operations = [
migrations.CreateModel(
name='UserMobileOTP',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)),
('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)),
('deleted', models.BooleanField(db_index=True, default=False)),
('is_used', models.BooleanField(default=False)),
('phone_number', models.CharField(max_length=14, validators=[care.utils.models.validators.PhoneNumberValidator(types=('mobile', 'landline'))])),
('otp', models.CharField(max_length=10)),
],
options={
'abstract': False,
},
),
]
8 changes: 8 additions & 0 deletions care/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,11 @@ def check_user_has_flag(cls, user_id: int, flag_name: FlagName) -> bool:
@classmethod
def get_all_flags(cls, user_id: int) -> tuple[FlagName]:
return super().get_all_flags(user_id)


class UserMobileOTP(BaseModel):
is_used = models.BooleanField(default=False)
phone_number = models.CharField(
max_length=14, validators=[mobile_or_landline_number_validator]
)
otp = models.CharField(max_length=10)
4 changes: 4 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,10 @@

OTP_SMS_TEMPLATE_PATH = env("OTP_SMS_TEMPLATE", default="sms/otp_sms.txt")

OTP_SMS_RESET_PASSWORD_TEMPLATE_PATH = env(
"OTP_SMS_RESET_PASSWORD_TEMPLATE", default="sms/otp_reset_sms.txt"
Comment thread
nandkishorr marked this conversation as resolved.
Outdated
)

USER_CREATE_PASSWORD_EMAIL_TEMPLATE_PATH = env(
"USER_CREATE_PASSWORD_TEMPLATE_PATH", default="email/user_create_password.html"
)
Expand Down
14 changes: 14 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
SpectacularSwaggerView,
)

from care.users.api.otp_viewset.reset_password import (
OTPResetConfirmView,
OTPResetSendView,
)
from care.users.api.viewsets.change_password import ChangePasswordView
from care.users.reset_password_views import (
ResetPasswordCheck,
Expand Down Expand Up @@ -62,6 +66,16 @@
ChangePasswordView.as_view(),
name="change_password_view",
),
path(
"api/v1/otp/password_reset/send/",
OTPResetSendView.as_view(),
name="otp_password_reset_send",
),
path(
"api/v1/otp/password_reset/confirm/",
OTPResetConfirmView.as_view(),
name="otp_password_reset_confirm",
),
path("api/v1/", include(api_router.urlpatterns)),
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
]
Expand Down
Loading