Skip to content

Commit c83b4c5

Browse files
reset password
1 parent 2008996 commit c83b4c5

File tree

11 files changed

+247
-6
lines changed

11 files changed

+247
-6
lines changed

account/business/reset_password.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from django.conf import settings
2+
from django.contrib.auth import get_user_model
3+
from django.contrib.auth.tokens import PasswordResetTokenGenerator
4+
from django.core.exceptions import ValidationError
5+
from django.utils.encoding import force_bytes
6+
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
7+
8+
from account.serializers import EmailResetPasswordLinkTaskSerializer
9+
from account.tasks import send_email_reset_password_link_task
10+
from keep_learning.utils.url import update_url_params
11+
12+
User = get_user_model()
13+
14+
15+
class ResetPasswordTokenInvalid(Exception):
16+
pass
17+
18+
19+
class ResetPasswordBusiness:
20+
token_generator = PasswordResetTokenGenerator()
21+
22+
def __init__(self, user):
23+
self.user = user
24+
25+
def send_email(self):
26+
url = self.get_link()
27+
serializer = EmailResetPasswordLinkTaskSerializer(instance=self.user)
28+
send_email_reset_password_link_task.delay(serializer.data, url)
29+
30+
def reset_password(self, password, token):
31+
self.check_token(token)
32+
self.user.set_password(password)
33+
self.user.save()
34+
35+
def get_link(self):
36+
token = self.token_generator.make_token(self.user)
37+
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
38+
url = update_url_params(settings.WEB_RESET_PASSWORD_URL, {'token': token, 'uid': uid})
39+
return url
40+
41+
def check_token(self, token):
42+
is_valid = self.token_generator.check_token(self.user, token)
43+
if not is_valid:
44+
raise ResetPasswordTokenInvalid
45+
46+
@staticmethod
47+
def get_user_by_email(email):
48+
return User.objects.filter(email=email).first()
49+
50+
@staticmethod
51+
def get_user_by_uid(uid):
52+
try:
53+
uid = urlsafe_base64_decode(uid).decode()
54+
user = User.objects.get(pk=uid)
55+
except (TypeError, ValueError, OverflowError, User.DoesNotExist, ValidationError):
56+
user = None
57+
return user

account/serializers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,20 @@ class MeSerializer(UserSerializer):
6565

6666
class ChangePasswordSerializer(serializers.Serializer):
6767
current_password = serializers.CharField(write_only=True)
68+
69+
70+
class EmailResetPasswordLinkSerializer(serializers.Serializer):
71+
email = serializers.EmailField()
72+
73+
74+
class ResetPasswordSerializer(serializers.Serializer):
75+
uid = serializers.CharField()
76+
token = serializers.CharField()
77+
password = serializers.CharField(write_only=True, validators=[validate_password])
6878
new_password = serializers.CharField(write_only=True, validators=[validate_password])
79+
80+
81+
class EmailResetPasswordLinkTaskSerializer(serializers.ModelSerializer):
82+
class Meta:
83+
model = User
84+
fields = ['name', 'email', 'date_joined']

account/tasks.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from celery import shared_task
2+
from dateutil.parser import isoparse
3+
from django.core.mail import send_mail
4+
from django.template import loader
5+
from django.utils import formats
6+
from django.utils.translation import gettext_lazy as _
7+
8+
9+
@shared_task
10+
def send_email_reset_password_link_task(recipient, url):
11+
return send_email_reset_password_link(recipient, url)
12+
13+
def send_email_reset_password_link(recipient, url):
14+
date_joined = recipient['date_joined']
15+
date_joined_dt = isoparse(date_joined)
16+
date_joined_str = formats.localize(date_joined_dt)
17+
recipient['date_joined'] = date_joined_str
18+
19+
context = {
20+
'url': url,
21+
'recipient': recipient,
22+
'app_name': _('Tango'),
23+
}
24+
title = loader.render_to_string('account/reset_password/email_title.txt', context)
25+
title = title.replace('\n', '') # Title cannot have newline
26+
message = loader.render_to_string('account/reset_password/email_body.txt', context)
27+
html_message = loader.render_to_string('account/reset_password/email_body.html', context)
28+
send_mail(title, message, None, [recipient['email']], html_message=html_message)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{% load i18n %}
2+
{% autoescape off %}
3+
4+
<h1>{% translate 'Hi there' %} :)</h1>
5+
6+
{% blocktranslate %}
7+
<p>You're receiving this email because you requested a password reset for your user account at <b>{{ app_name }}</b>.</p>
8+
{% endblocktranslate %}
9+
10+
<p>{% translate "Please click this link and then follow the instruction to reset your password:" %}</p>
11+
<a href="{{ url }}">{{ url }}</a>
12+
13+
<p>{% translate 'Your account information:' %}</p>
14+
<ul>
15+
<li>{% translate 'Name' %}: {{ recipient.name }}</li>
16+
<li>{% translate 'Email' %}: {{ recipient.email }}</li>
17+
<li>{% translate 'Date joined' %}: {{ recipient.date_joined }}</li>
18+
</ul>
19+
20+
<p>{% translate "Thanks for using our app!" %}</p>
21+
22+
{% blocktranslate %}
23+
<p>The <b>{{ app_name }}</b> team.</p>
24+
{% endblocktranslate %}
25+
26+
{% endautoescape %}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{% load i18n %}
2+
{% autoescape off %}
3+
4+
{% translate 'Hi there' %} :)
5+
6+
{% blocktranslate %}
7+
You're receiving this email because you requested a password reset for your user account at {{ app_name }}.
8+
{% endblocktranslate %}
9+
10+
{% translate "Please click this link and then follow the instruction to reset your password:" %}
11+
{{ url }}
12+
13+
{% translate 'Your account information:' %}
14+
{% translate 'Name' %}: {{ recipient.name }}
15+
{% translate 'Email' %}: {{ recipient.email }}
16+
{% translate 'Date joined' %}: {{ recipient.date_joined }}
17+
18+
{% translate "Thanks for using our app!" %}
19+
20+
{% blocktranslate %}
21+
The {{ app_name }} team
22+
{% endblocktranslate %}
23+
24+
{% endautoescape %}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{% load i18n %}
2+
{% autoescape off %}
3+
4+
{% blocktranslate %}
5+
Password reset on {{ app_name }}
6+
{% endblocktranslate %}
7+
8+
{% endautoescape %}

account/views.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22
from django.utils.decorators import classonlymethod, method_decorator
33
from django.utils.translation import gettext as _
44
from django.views.decorators.debug import sensitive_post_parameters
5-
from rest_framework import mixins
5+
from rest_framework import mixins, status
66
from rest_framework.decorators import action
7-
from rest_framework.exceptions import PermissionDenied
7+
from rest_framework.exceptions import NotFound, PermissionDenied
88
from rest_framework.generics import get_object_or_404
99
from rest_framework.permissions import AllowAny
1010
from rest_framework.response import Response
1111
from rest_framework.viewsets import GenericViewSet
1212

1313
from account import business
14-
from account.serializers import (ChangePasswordSerializer, MeSerializer,
15-
RegisterTeacherSerializer, UserSerializer)
14+
from account.business.reset_password import (ResetPasswordBusiness,
15+
ResetPasswordTokenInvalid)
16+
from account.serializers import (ChangePasswordSerializer,
17+
EmailResetPasswordLinkSerializer,
18+
MeSerializer, RegisterTeacherSerializer,
19+
ResetPasswordSerializer, UserSerializer)
1620

1721
User = get_user_model()
1822

@@ -88,6 +92,57 @@ def change_password(self, request):
8892
user.save()
8993
return Response()
9094

95+
@action(
96+
detail=False, methods=['POST'],
97+
url_path='email-reset-password-link',
98+
serializer_class=EmailResetPasswordLinkSerializer,
99+
permission_classes=[AllowAny],
100+
)
101+
def email_reset_password_link(self, request):
102+
"""
103+
Send reset password link email.
104+
"""
105+
serializer = self.get_serializer(data=request.data)
106+
serializer.is_valid(raise_exception=True)
107+
email = serializer.validated_data['email']
108+
109+
user = ResetPasswordBusiness.get_user_by_email(email)
110+
if user:
111+
business = ResetPasswordBusiness(user)
112+
business.send_email()
113+
114+
return Response(status=status.HTTP_202_ACCEPTED)
115+
116+
@action(
117+
detail=False, methods=['POST'],
118+
url_path='reset-password',
119+
serializer_class=ResetPasswordSerializer,
120+
permission_classes=[AllowAny],
121+
)
122+
def reset_password(self, request):
123+
"""
124+
Reset user's password.
125+
Return 403 if `token` is not valid.
126+
Return 404 if `uid` is not valid.
127+
"""
128+
serializer = self.get_serializer(data=request.data)
129+
serializer.is_valid(raise_exception=True)
130+
uid = serializer.validated_data['uid']
131+
token = serializer.validated_data['token']
132+
password = serializer.validated_data['password']
133+
134+
user = ResetPasswordBusiness.get_user_by_uid(uid)
135+
if user:
136+
business = ResetPasswordBusiness(user)
137+
try:
138+
business.reset_password(password, token)
139+
except ResetPasswordTokenInvalid:
140+
raise PermissionDenied(_('Reset password token invalid.'))
141+
else:
142+
raise NotFound(_('Cannot find user with the given `uid`.'))
143+
144+
return Response()
145+
91146

92147
class MeViewSet(mixins.ListModelMixin,
93148
mixins.RetrieveModelMixin,

classroom/tasks.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ def send_temp_password_for_new_students(student_emails, temp_passwords, teacher_
3535
alternatives=[
3636
(html_message, 'text/html')
3737
],
38-
from_email='rockstarrprogrammerr@gmail.com',
3938
to=[student_email],
4039
)
4140
messages.append(message)

keep_learning/settings.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
DEFAULT_FROM_EMAIL=(str, ''),
5757

5858
CELERY_BROKER_URL=(str, 'amqp://kl_user:kl_password@localhost:5672/kl_vhost'),
59-
WEB_LOGIN_URL=(str, 'http://localhost:8080/login/')
59+
WEB_LOGIN_URL=(str, 'http://localhost:8080/login'),
60+
WEB_RESET_PASSWORD_URL=(str, 'http://localhost:8080/new-password'),
6061
)
6162
# reading .env file
6263
env_file = str(BASE_DIR / '.env')
@@ -281,5 +282,6 @@
281282
del REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']
282283

283284
WEB_LOGIN_URL = env('WEB_LOGIN_URL')
285+
WEB_RESET_PASSWORD_URL = env('WEB_RESET_PASSWORD_URL')
284286

285287
MAX_UPLOAD_SIZE_MEGABYTES = 10

keep_learning/utils/url.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from urllib.parse import urlparse, unquote, urlencode, parse_qsl, ParseResult
2+
3+
4+
def get_url_params(url):
5+
url = unquote(url)
6+
p_url = urlparse(url)
7+
p_qs = parse_qsl(p_url.query)
8+
p_params = dict(p_qs)
9+
return p_params
10+
11+
12+
def update_url_params(url, new_params):
13+
url = unquote(url)
14+
p_url = urlparse(url)
15+
p_qs = parse_qsl(p_url.query)
16+
p_params = dict(p_qs)
17+
p_params.update(new_params)
18+
encoded_params = urlencode(p_params, doseq=True)
19+
20+
new_url = ParseResult(
21+
p_url.scheme, p_url.netloc, p_url.path,
22+
p_url.params, encoded_params, p_url.fragment
23+
).geturl()
24+
25+
return new_url

0 commit comments

Comments
 (0)