Skip to content

Commit 55436f3

Browse files
authored
Merge pull request #31 from tsotetsi/tf-21-setup-mailpit-service
Add mailpit as a docker-service.
2 parents acd031e + a1df3fc commit 55436f3

File tree

12 files changed

+309
-20
lines changed

12 files changed

+309
-20
lines changed

backend/user_service/.coverage

52 KB
Binary file not shown.

backend/user_service/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@
7878

7979
Visit your localhost om port 8000: [http://127.0.0.1:8000](http://127.0.0.1:8000)
8080

81+
82+
### Running tests and test coverage
83+
84+
1. Run tests with pytest.
85+
```bash
86+
pytest
87+
```
88+
2. Run tests, check your test coverage, and generate HTML coverage report.
89+
```bash
90+
coverage run --source=. -m pytest
91+
coverage html
92+
open htmlcov/index.html
93+
```
94+
8195
### Build User-Service docker image and publish to registry.
8296

8397
1. Tag(e.g. v1, latest) built image from the previous step.

backend/user_service/config/settings/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,9 @@
205205
# Email backend (for development) TODO: Use mailpit
206206
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console for testing
207207

208+
ACCOUNT_ADAPTER = 'users.adapters.CustomAccountAdapter'
209+
FRONTEND_URL = 'http://localhost:8000/api'
210+
208211
# By Default swagger ui is available only to admin user(s). You can change permission classes to change that
209212
# See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings
210213
SPECTACULAR_SETTINGS = {

backend/user_service/config/settings/local.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
# TODO: Set static IP address for minikube, minikube start --static=192.168.49.2
1212
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "user-service"] # noqa: S104
1313

14+
# EMAIL
15+
# ------------------------------------------------------------------------------
16+
# https://docs.djangoproject.com/en/dev/ref/settings/#email-host
17+
EMAIL_HOST = os.getenv("EMAIL_HOST", default="mailpit")
18+
# https://docs.djangoproject.com/en/dev/ref/settings/#email-port
19+
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
20+
EMAIL_PORT = 1025 # Mailpit's SMTP port
21+
EMAIL_USE_TLS = False # Mailpit does not use TLS by default
22+
EMAIL_USE_SSL = False # Mailpit does not use SSL by default
23+
1424
# django-debug-toolbar
1525
# ------------------------------------------------------------------------------
1626
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites

backend/user_service/config/urls.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from allauth.account.views import confirm_email, email_verification_sent
1+
from allauth.account.views import email_verification_sent, PasswordResetFromKeyView
22
from django.contrib import admin
33
from django.urls import path, include, re_path
44
from django.conf import settings
@@ -8,7 +8,7 @@
88
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
99
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
1010

11-
from users.api.views import RegisterView, UserProfileView, CustomConfirmEmailView
11+
from users.api.views import RegisterView, UserProfileView, CustomConfirmEmailView, PasswordResetView, CustomPasswordResetFromKeyView
1212

1313

1414
urlpatterns = [
@@ -19,8 +19,10 @@
1919
urlpatterns += [
2020
# API BASE URL For Authentication JWT.
2121
path('api/', include("config.api_router")),
22-
path('api/login', RegisterView.as_view(), name='account_login'),
22+
#path('api/login', RegisterView.as_view(), name='account_login'), # Delete this.
2323
path('api/register', RegisterView.as_view(), name='register'),
24+
path('api/password/reset/', PasswordResetView.as_view(), name='password_reset'),
25+
path('api/password/reset/key/<uidb36>/<key>/', CustomPasswordResetFromKeyView.as_view(), name='account_reset_password_from_key'),
2426
path('api/account-confirm-email/<str:key>/', CustomConfirmEmailView.as_view() , name='account_confirm_email'),
2527
path('api/account-email-verification-sent', email_verification_sent, name='account_email_verification_sent'),
2628
path('api/profile', UserProfileView.as_view(), name='profile'),

backend/user_service/docker-compose.local.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ services:
5858
depends_on:
5959
- prometheus-service
6060

61+
mailpit-service:
62+
image: docker.io/axllent/mailpit:latest
63+
container_name: user_service_mailpit
64+
restart: unless-stopped
65+
volumes:
66+
- ./data:/data
67+
ports:
68+
- "8025:8025"
69+
- "1025:1025"
70+
environment:
71+
MP_MAX_MESSAGES: 5000
72+
MP_DATABASE: /data/mailpit.db
73+
MP_SMTP_AUTH_ACCEPT_ANY: 1
74+
MP_SMTP_AUTH_ALLOW_INSECURE: 1
75+
6176
user-service:
6277
depends_on:
6378
- postgres-service

backend/user_service/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ python_files = [
1111
# ==== Coverage ====
1212
[tool.coverage.run]
1313
include = ["user_service/**"]
14-
omit = ["*/migrations/*", "*/tests/*"]
14+
omit = ["*/migrations/*", "*/tests/*", "*/.venv/*", "*/staticfiles/*", "*/requirements/*"]
1515
plugins = ["django_coverage_plugin"]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from allauth.account.adapter import DefaultAccountAdapter
2+
from django.conf import settings
3+
4+
class CustomAccountAdapter(DefaultAccountAdapter):
5+
def send_password_reset_mail(self, user, email, context):
6+
# Ensure the context contains the correct uid and key.
7+
context['password_reset_url'] = (
8+
f"{settings.FRONTEND_URL}/password/reset/key/{context['uid']}/{context['key']}/"
9+
)
10+
super().send_password_reset_mail(user, email, context)

backend/user_service/users/api/serializers.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from django.core.exceptions import ValidationError
2+
from django.contrib.auth.password_validation import validate_password
3+
from allauth.account.forms import ResetPasswordForm
14
from rest_framework import serializers
25

36
from users.models import User
@@ -16,11 +19,45 @@ class RegisterSerializer(serializers.ModelSerializer):
1619

1720
class Meta:
1821
model = User
19-
fields = ("name", "email", "phone_number", "password", "is_customer", "is_organizer")
22+
fields = ("id", "name", "email", "phone_number", "password", "is_customer", "is_organizer")
2023

2124
def create(self, validated_data):
2225
password = validated_data.pop('password')
2326
user = User.objects.create(**validated_data)
2427
user.set_password(password)
2528
user.save()
26-
return user
29+
return user
30+
31+
32+
class PasswordResetSerializer(serializers.Serializer):
33+
email = serializers.EmailField()
34+
35+
def validate_email(self, value):
36+
# Check if the email exists in the database.
37+
if not User.objects.filter(email=value).exists():
38+
raise serializers.ValidationError("User with this email address does not exist.")
39+
return value
40+
41+
def save(self, **kwargs):
42+
request = self.context.get('request')
43+
form = ResetPasswordForm(data=self.validated_data)
44+
if form.is_valid():
45+
form.save(request=request)
46+
47+
48+
class PasswordResetConfirmSerializer(serializers.Serializer):
49+
new_password1 = serializers.CharField(required=True, write_only=True)
50+
new_password2 = serializers.CharField(required=True, write_only=True)
51+
52+
def validate_new_password1(self, value):
53+
try:
54+
# Validate the new password against the validators in base.py.
55+
validate_password(value)
56+
except ValidationError as e:
57+
raise serializers.ValidationError(list(e.messages))
58+
return value
59+
60+
def validate(self, data):
61+
if data['new_password1'] != data['new_password2']:
62+
raise serializers.ValidationError("The two password fields didn't match.")
63+
return data

backend/user_service/users/api/views.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import uuid
22

3-
from allauth.account.views import ConfirmEmailView
4-
from allauth.account.utils import complete_signup
3+
from allauth.account.forms import UserTokenForm
4+
from allauth.account.utils import complete_signup, url_str_to_user_pk
55
from allauth.account.internal import flows
66
from allauth.account.models import (
77
get_emailconfirmation_model,
8+
EmailAddress
89
)
910

1011
from django.http import Http404
@@ -16,14 +17,14 @@
1617
from rest_framework.mixins import RetrieveModelMixin
1718
from rest_framework.mixins import UpdateModelMixin
1819
from rest_framework.viewsets import GenericViewSet
19-
from rest_framework.decorators import action
20+
from rest_framework.decorators import action, renderer_classes
2021
from rest_framework.response import Response
2122
from rest_framework.views import APIView
2223
from rest_framework import status, permissions
2324
from rest_framework_simplejwt.tokens import RefreshToken
2425

2526
from users.models import User
26-
from users.api.serializers import UserSerializer, RegisterSerializer
27+
from users.api.serializers import UserSerializer, RegisterSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer
2728
from config.settings.base import CONFIRM_EMAIL_ON_GET
2829

2930
class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet):
@@ -95,6 +96,71 @@ def get(self, *args, **kwargs):
9596
return Response({"detail": "Not accepting GET request on the endpoint"}, status=status.HTTP_400_BAD_REQUEST)
9697

9798

99+
class CustomPasswordResetFromKeyView(APIView):
100+
permission_classes = [permissions.AllowAny]
101+
102+
def get_user(self, uidb36):
103+
try:
104+
uid_int = url_str_to_user_pk(uidb36)
105+
user = User.objects.get(pk=uid_int)
106+
if not user:
107+
return None
108+
return user
109+
except(ValueError, User.DoesNotExist):
110+
return Response({"detail": "Invalid user ID."})
111+
112+
def get(self, request, uidb36, key, *args, **kwargs):
113+
# Check if the reset link is valid
114+
user = self.get_user(uidb36)
115+
if not user:
116+
return Response(
117+
{"detail": "Invalid reset link."},
118+
status=status.HTTP_400_BAD_REQUEST,
119+
)
120+
121+
# Return a response indicating the reset link is valid
122+
return Response(
123+
{"detail": "Please submit your new password."},
124+
status=status.HTTP_200_OK,
125+
)
126+
127+
def post(self, request, uidb36, key):
128+
serializer = PasswordResetConfirmSerializer(data=request.data)
129+
130+
if serializer.is_valid(raise_exception=True):
131+
# Decode the user ID
132+
user = self.get_user(uidb36)
133+
134+
# Validate the reset key.
135+
token_form = UserTokenForm(data={'uidb36': uidb36, 'key': key})
136+
if not token_form.is_valid():
137+
return Response({"detail": "Invalid or expired reset key."}, status=status.HTTP_400_BAD_REQUEST)
138+
139+
# Ensure the user email is verified.
140+
if not EmailAddress.objects.filter(user=user, verified=True).exists():
141+
return Response({"detail": "Email address not verified."}, status=status.HTTP_400_BAD_REQUEST)
142+
143+
# Get the new password from the request
144+
new_password = request.data.get("new_password1")
145+
146+
# Set the new password
147+
user.set_password(new_password)
148+
user.save()
149+
return Response({"detail": "Password has been reset successfully"}, status=status.HTTP_200_OK)
150+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
151+
152+
class PasswordResetView(APIView):
153+
permission_classes = [permissions.AllowAny]
154+
155+
def post(self, request):
156+
serializer = PasswordResetSerializer(data=request.data, context={'request': request})
157+
158+
if serializer.is_valid():
159+
serializer.save()
160+
return Response({"detail": "Password reset email has been sent."}, status=status.HTTP_200_OK)
161+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
162+
163+
98164
class UserProfileView(APIView):
99165
permission_classes = [permissions.IsAuthenticated]
100166

0 commit comments

Comments
 (0)