Coverage for src/accounts/serializers/user_credential.py: 66%

116 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-09-21 16:24 +0300

1""" 

2This module defines serializers for user registration, login, logout, and password change operations. 

3""" 

4 

5from django.contrib.auth import get_user_model 

6from django.contrib.auth.password_validation import validate_password 

7from django.core.exceptions import ValidationError 

8from django.contrib.auth.tokens import default_token_generator 

9from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode 

10from django.utils.encoding import force_bytes, force_str 

11from django.conf import settings 

12from django.template.loader import render_to_string 

13from django.utils.html import strip_tags 

14 

15from rest_framework import serializers 

16 

17from src.common.views import _send_email 

18 

19from src.accounts.constants import UserErrorMessages 

20from src.accounts.validators.models import ( 

21 UsernameValidator, 

22 EmailOrUsernameValidator, 

23) 

24 

25UserModel = get_user_model() 

26 

27 

28class UserRegisterSerializer(serializers.ModelSerializer): 

29 """ 

30 Serializer for registering a new user. 

31 

32 Inherits from ModelSerializer, which automatically generates fields based on the User model. 

33 Handles validation and creation of new user instances, including password hashing and email consent. 

34 """ 

35 

36 # Password field: write_only means it won't be returned in API responses. 

37 # trim_whitespace=False ensures passwords are not altered by removing spaces. 

38 password = serializers.CharField(write_only=True, trim_whitespace=False) 

39 

40 # Consent field: write_only as it's only needed during registration. 

41 agreed_to_emails = serializers.BooleanField(write_only=True) 

42 

43 class Meta: 

44 model = UserModel 

45 # Fields to include in the API (must match model fields or serializer fields) 

46 fields = [ 

47 'email', 

48 'username', 

49 'password', 

50 'agreed_to_emails', 

51 ] 

52 

53 def validate_password(self, value): 

54 validate_password(value) 

55 

56 return value 

57 

58 def validate_username(self, value): 

59 """ 

60 Validate the username using a custom validator. 

61 Ensures the username meets project-specific requirements (e.g., allowed characters). 

62 """ 

63 username_validator = UsernameValidator() 

64 

65 username_validator(value) 

66 

67 return value 

68 

69 def validate_agreed_to_emails(self, value): 

70 """ 

71 Ensure the user has agreed to receive email updates. 

72 """ 

73 if not value: 

74 raise serializers.ValidationError( 

75 UserErrorMessages.AGREED_TO_EMAILS 

76 ) 

77 

78 return value 

79 

80 def create(self, validated_data): 

81 """ 

82 Create a new user instance using the validated data. 

83 Uses the custom manager's create_user method, which handles password hashing and other logic. 

84 """ 

85 user = UserModel.objects.create_user(**validated_data) 

86 

87 return user 

88 

89 

90class UserLoginRequestSerializer(serializers.Serializer): 

91 """ 

92 Serializer for user login requests. 

93 

94 Uses a plain Serializer (not ModelSerializer) because login does not create or update model instances. 

95 Accepts either email or username and a password. 

96 """ 

97 

98 email_or_username = serializers.CharField() 

99 password = serializers.CharField() 

100 

101 def validate_email_or_username(self, value): 

102 """ 

103 Validate the email or username using a custom validator. 

104 Ensures the input is a valid email or username format. 

105 """ 

106 email_or_username_validator = EmailOrUsernameValidator() 

107 

108 email_or_username_validator(value) 

109 

110 return value 

111 

112 

113class UserLoginResponseSerializer(serializers.Serializer): 

114 """ 

115 Serializer for user login responses. 

116 

117 Returns authentication tokens and a message after successful login. 

118 """ 

119 

120 refresh = serializers.CharField() 

121 access = serializers.CharField() 

122 message = serializers.CharField() 

123 

124 

125class UserLogoutRequestSerializer(serializers.Serializer): 

126 """ 

127 Serializer for user logout requests. 

128 

129 Accepts a refresh token to invalidate the session. 

130 """ 

131 

132 refresh = serializers.CharField() 

133 

134 

135class UserLogoutResponseSerializer(serializers.Serializer): 

136 """ 

137 Serializer for user logout responses. 

138 

139 Returns a message confirming logout. 

140 """ 

141 

142 message = serializers.CharField() 

143 

144 

145class UserPasswordChangeSerializer(serializers.Serializer): 

146 """ 

147 Serializer for changing a user's password. 

148 

149 Handles validation of current and new passwords. 

150 """ 

151 

152 current_password = serializers.CharField( 

153 write_only=True, trim_whitespace=False 

154 ) 

155 new_password = serializers.CharField( 

156 write_only=True, trim_whitespace=False 

157 ) 

158 

159 def validate_current_password(self, value): 

160 """ 

161 Ensure the current password matches the user's actual password. 

162 """ 

163 user = self.context['request'].user 

164 

165 if not user.check_password(value): 

166 raise serializers.ValidationError( 

167 UserErrorMessages.INCORRECT_PASSWORD 

168 ) 

169 

170 return value 

171 

172 def validate_new_password(self, value): 

173 """ 

174 Validate the new password using Django's and custom password validators. 

175 """ 

176 validate_password(value, user=self.context['request'].user) 

177 

178 return value 

179 

180 def validate(self, attrs): 

181 """ 

182 Object-level validation to ensure the new password is different from the current password. 

183 """ 

184 if attrs['current_password'] == attrs['new_password']: 

185 raise serializers.ValidationError( 

186 { 

187 'new_password': UserErrorMessages.NEW_PASSWORD_SAME_AS_CURRENT 

188 } 

189 ) 

190 

191 return attrs 

192 

193 def save(self): 

194 """ 

195 Set the new password for the user and save the user instance. 

196 This method is called after validation passes. 

197 """ 

198 user = self.context['request'].user 

199 

200 user.set_password(self.validated_data['new_password']) 

201 

202 user.save() 

203 

204 return user 

205 

206 

207class UserPasswordResetRequestSerializer(serializers.Serializer): 

208 email = serializers.EmailField() 

209 

210 def save(self): 

211 email = self.validated_data['email'] 

212 

213 try: 

214 user = UserModel.objects.get(email=email, is_active=True) 

215 

216 # converts 123 to something like MTIz 

217 # It's reversible -> we can decode it to get the user ID back 

218 uid = urlsafe_base64_encode(force_bytes(user.pk)) 

219 

220 # Creates secure tokens tied to the user 

221 token = default_token_generator.make_token(user) 

222 

223 # Reset URL to provide into the email 

224 reset_url = f'{settings.FRONTEND_URL}/reset-password/{uid}/{token}' 

225 

226 # Send email 

227 html_message = render_to_string( 

228 'mailer/reset-password.html', {'reset_url': reset_url} 

229 ) 

230 plain_message = strip_tags(html_message) 

231 

232 _send_email.delay( 

233 subject='Reset Password', 

234 message=plain_message, 

235 html_message=html_message, 

236 from_email=settings.EMAIL_HOST_USER, 

237 recipient_list=(user.email,), 

238 ) 

239 

240 except UserModel.DoesNotExist: 

241 pass 

242 

243 

244class UserPasswordResetConfirmSerializer(serializers.Serializer): 

245 # Fields expected from the frontend 

246 uid = serializers.CharField() 

247 token = serializers.CharField() 

248 new_password = serializers.CharField() 

249 confirm_new_password = serializers.CharField() 

250 

251 def validate(self, attrs): 

252 try: 

253 uid = attrs['uid'] 

254 token = attrs['token'] 

255 

256 # Decode the UID to get the original user ID (e.g., from MTIz to 123) 

257 user_id = force_str(urlsafe_base64_decode(uid)) 

258 

259 # Try to fetch the user with the decoded ID 

260 user = UserModel.objects.get(pk=user_id) 

261 

262 # Check if the token is valid for this user 

263 if not default_token_generator.check_token(user, token): 

264 raise serializers.ValidationError( 

265 UserErrorMessages.INVALID_TOKEN 

266 ) 

267 

268 new_password = attrs['new_password'] 

269 confirm_new_password = attrs['confirm_new_password'] 

270 

271 # Validate passwords match 

272 if new_password != confirm_new_password: 

273 raise serializers.ValidationError( 

274 { 

275 'confirm_new_password': UserErrorMessages.PASSWORDS_DO_NOT_MATCH, 

276 } 

277 ) 

278 

279 # Validate new password using Django's validators 

280 try: 

281 validate_password(new_password, user=user) 

282 except ValidationError as e: 

283 raise serializers.ValidationError({'new_password': e.messages}) 

284 

285 # Store the user in validated_data so we can use it in save() 

286 attrs['user'] = user 

287 

288 return attrs 

289 

290 # Handle cases where UID is invalid or user doesn't exist 

291 except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist): 

292 raise serializers.ValidationError(UserErrorMessages.INVALID_TOKEN) 

293 

294 def save(self): 

295 # Get user and new password from validated data 

296 user = self.validated_data['user'] 

297 new_password = self.validated_data['new_password'] 

298 

299 user.set_password(new_password) 

300 user.save() 

301 

302 html_message = render_to_string( 

303 'mailer/password-has-been-reset.html', 

304 ) 

305 

306 plain_message = strip_tags(html_message) 

307 

308 _send_email.delay( 

309 subject='Your password has been reset', 

310 message=plain_message, 

311 html_message=html_message, 

312 from_email=settings.EMAIL_HOST_USER, 

313 recipient_list=(user.email,), 

314 )