diff --git a/.env.example b/.env.example index d5827b72..ef598ffa 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ EMAIL_PORT=port EMAIL_HOST_USER=user EMAIL_HOST_PASSWORD=password +GOOGLE_CLIENT_ID=id + CELERY_BROKER_URL= amqp://guest:guest@rabbitmq:5672// #pragma: allowlist secret CELERY_RESULT_BACKEND= db+postgresql://postgres:postgres@postgres:5432/djangoindia-db #pragma: allowlist secret diff --git a/backend/djangoindia/api/serializers/__init__.py b/backend/djangoindia/api/serializers/__init__.py index 811063a5..91a17c43 100644 --- a/backend/djangoindia/api/serializers/__init__.py +++ b/backend/djangoindia/api/serializers/__init__.py @@ -2,6 +2,8 @@ from .event import EventRegistrationSerializer, EventSerializer from .media_library import FolderSerializer from .partner_and_sponsor import CommunityPartnerAndSponsorSerializer +from .user import ChangePasswordSerializer, UserMeSerializer, UserSerializer +from .volunteer import VolunteerSerializer __all__ = [ @@ -11,4 +13,8 @@ "EventSerializer", "FolderSerializer", "CommunityPartnerAndSponsorSerializer", + "UserSerializer", + "UserMeSerializer", + "ChangePasswordSerializer", + "VolunteerSerializer", ] diff --git a/backend/djangoindia/api/serializers/base.py b/backend/djangoindia/api/serializers/base.py new file mode 100644 index 00000000..0c6bba46 --- /dev/null +++ b/backend/djangoindia/api/serializers/base.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) diff --git a/backend/djangoindia/api/serializers/user.py b/backend/djangoindia/api/serializers/user.py new file mode 100644 index 00000000..1e8b14ee --- /dev/null +++ b/backend/djangoindia/api/serializers/user.py @@ -0,0 +1,113 @@ +# Module import +from rest_framework import serializers + +from djangoindia.db.models import User + +from .base import BaseSerializer + + +class UserSerializer(BaseSerializer): + class Meta: + model = User + fields = "__all__" + read_only_fields = [ + "id", + "created_at", + "updated_at", + "is_superuser", + "is_staff", + "is_onboarded", + "is_password_autoset", + "is_email_verified", + ] + extra_kwargs = {"password": {"write_only": True}} + + +class UserMeSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "avatar", + "created_at", + "email", + "first_name", + "last_name", + "is_active", + "is_email_verified", + "is_onboarded", + "mobile_number", + "user_timezone", + "username", + "is_password_autoset", + "gender", + "organization", + ] + read_only_fields = fields + + +class ChangePasswordSerializer(serializers.Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) + confirm_password = serializers.CharField(required=True, min_length=8) + + def validate(self, data): + if data.get("old_password") == data.get("new_password"): + raise serializers.ValidationError( + {"message": "New password cannot be same as old password."} + ) + + if data.get("new_password") != data.get("confirm_password"): + raise serializers.ValidationError( + {"message": "Confirm password should be same as the new password."} + ) + + return data + + +class ResetPasswordSerializer(serializers.Serializer): + """ + Serializer for password change endpoint. + """ + + new_password = serializers.CharField(required=True, min_length=8) + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "username", + "email", + "role", + ] + read_only_fields = [ + "id", + "username", + "email", + ] + + +class UserAdminLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "username", + "email", + ] + read_only_fields = [ + "id", + ] diff --git a/backend/djangoindia/api/urls/__init__.py b/backend/djangoindia/api/urls/__init__.py index 6fb8f4a0..a9113df0 100644 --- a/backend/djangoindia/api/urls/__init__.py +++ b/backend/djangoindia/api/urls/__init__.py @@ -1,9 +1,16 @@ +from .authentication import urlpatterns as auth_urls from .communication import urlpatterns as communication_urls from .event import urlpatterns as event_urls from .media_library import urlpatterns as media_library_urls from .partner_and_sponsor import urlpatterns as community_partner_urls +from .user import urlpatterns as user_urls urlpatterns = ( - event_urls + communication_urls + community_partner_urls + media_library_urls + event_urls + + communication_urls + + community_partner_urls + + media_library_urls + + user_urls + + auth_urls ) diff --git a/backend/djangoindia/api/urls/authentication.py b/backend/djangoindia/api/urls/authentication.py new file mode 100644 index 00000000..e7e250ba --- /dev/null +++ b/backend/djangoindia/api/urls/authentication.py @@ -0,0 +1,39 @@ +from django.urls import path + +from djangoindia.api.views import ( + ForgotPasswordEndpoint, + OauthEndpoint, + RequestEmailVerificationEndpoint, + ResetPasswordEndpoint, + SignInEndpoint, + SignOutEndpoint, + SignUpEndpoint, + VerifyEmailEndpoint, +) + + +urlpatterns = [ + path("social-auth/", OauthEndpoint.as_view(), name="oauth"), + # Auth + path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"), + path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), + path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), + # email verification + path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"), + path( + "request-email-verify/", + RequestEmailVerificationEndpoint.as_view(), + name="request-reset-email", + ), + # Password Manipulation + path( + "reset-password///", + ResetPasswordEndpoint.as_view(), + name="password-reset", + ), + path( + "forgot-password/", + ForgotPasswordEndpoint.as_view(), + name="forgot-password", + ), +] diff --git a/backend/djangoindia/api/urls/user.py b/backend/djangoindia/api/urls/user.py new file mode 100644 index 00000000..2db549ec --- /dev/null +++ b/backend/djangoindia/api/urls/user.py @@ -0,0 +1,39 @@ +from django.urls import path + +from djangoindia.api.views import ( + ChangePasswordEndpoint, + SetUserPasswordEndpoint, + UpdateUserOnBoardedEndpoint, + UserEndpoint, +) + + +urlpatterns = [ + # User Profile + path( + "users/me/", + UserEndpoint.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "deactivate", + } + ), + name="users", + ), + path( + "users/me/onboard/", + UpdateUserOnBoardedEndpoint.as_view(), + name="user-onboard", + ), + path( + "users/me/set-password/", + SetUserPasswordEndpoint.as_view(), + name="set-password", + ), + path( + "users/me/change-password/", + ChangePasswordEndpoint.as_view(), + name="change-password", + ), +] diff --git a/backend/djangoindia/api/views/__init__.py b/backend/djangoindia/api/views/__init__.py index 85fcbc9f..187ed452 100644 --- a/backend/djangoindia/api/views/__init__.py +++ b/backend/djangoindia/api/views/__init__.py @@ -1,7 +1,20 @@ +from .authentication import ( + ChangePasswordEndpoint, + ForgotPasswordEndpoint, + OauthEndpoint, + RequestEmailVerificationEndpoint, + ResetPasswordEndpoint, + SetUserPasswordEndpoint, + SignInEndpoint, + SignOutEndpoint, + SignUpEndpoint, + VerifyEmailEndpoint, +) from .communication import ContactUsAPIView, SubscriberAPIView from .event import EventAPIView, EventAttendeeViewSet from .media_library import FolderLiteSerializer, FolderSerializer from .partner_and_sponsor import CommunityPartnerAndSponsorAPIView +from .user import UpdateUserOnBoardedEndpoint, UserEndpoint __all__ = [ @@ -12,4 +25,16 @@ "FolderLiteSerializer", "FolderSerializer", "CommunityPartnerAndSponsorAPIView", + "UserEndpoint", + "UpdateUserOnBoardedEndpoint", + "ChangePasswordEndpoint", + "ForgotPasswordEndpoint", + "ResetPasswordEndpoint", + "SetUserPasswordEndpoint", + "SignInEndpoint", + "SignOutEndpoint", + "SignUpEndpoint", + "VerifyEmailEndpoint", + "RequestEmailVerificationEndpoint", + "OauthEndpoint", ] diff --git a/backend/djangoindia/api/views/authentication.py b/backend/djangoindia/api/views/authentication.py new file mode 100644 index 00000000..b786339e --- /dev/null +++ b/backend/djangoindia/api/views/authentication.py @@ -0,0 +1,505 @@ +# Python imports +import os +import uuid + +import jwt + +from google.auth.transport import requests as google_auth_request +from google.oauth2 import id_token +from rest_framework import exceptions, status +from rest_framework.permissions import AllowAny, IsAuthenticated + +# Third Party modules +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken + +## Django imports +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.utils import timezone +from django.utils.encoding import DjangoUnicodeDecodeError, smart_bytes, smart_str +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode + +from djangoindia.api.serializers.user import ( + ChangePasswordSerializer, + ResetPasswordSerializer, + UserSerializer, +) +from djangoindia.api.views.base import BaseAPIView +from djangoindia.bg_tasks.auth.email_verification_task import email_verification_task +from djangoindia.bg_tasks.auth.forgot_password_task import forgot_password_task + +# Module imports +from djangoindia.db.models import SocialLoginConnection, User + + +def generate_password_token(user): + uidb64 = urlsafe_base64_encode(smart_bytes(user.uuid)) + token = PasswordResetTokenGenerator().make_token(user) + + return uidb64, token + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) + + +def validate_google_token(token, client_id): + try: + id_info = id_token.verify_oauth2_token( + token, google_auth_request.Request(), client_id + ) + email = id_info.get("email") + first_name = id_info.get("given_name") + last_name = id_info.get("family_name", "") + picture = id_info.get("picture", "") + data = { + "email": email, + "first_name": first_name, + "last_name": last_name, + "picture": picture, + } + return data + except Exception as e: + raise exceptions.AuthenticationFailed("detail with Google connection.") + + +class OauthEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def post(self, request): + try: + medium = request.data.get("medium", False) + id_token = request.data.get("credential", False) + client_id = request.data.get("clientId", False) + data = {} + GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID") + + if not medium or not id_token: + return Response( + { + "message": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if medium == "google": + if not GOOGLE_CLIENT_ID: + return Response( + {"message": "Google login is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + data = validate_google_token(id_token, client_id) + + email = data.get("email", None) + if email is None: + return Response( + { + "message": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if "@" in email: + user = User.objects.get(email=email) + email = data["email"] + email_verified = True + else: + return Response( + { + "message": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user.is_active = True + user.is_email_verified = email_verified + user.save() + + SocialLoginConnection.objects.update_or_create( + medium=medium, + extra_data={}, + user=user, + defaults={ + "token_data": {"id_token": id_token}, + "last_login_at": timezone.now(), + }, + ) + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + return Response(data, status=status.HTTP_200_OK) + + except User.DoesNotExist: + if "@" in email: + email = data["email"] + email_verified = True + username = email.split("@")[0] + else: + return Response( + { + "message": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create( + username=username, + email=email, + first_name=data.get("first_name"), + last_name=data.get("last_name"), + avatar=data.get("picture"), + is_email_verified=email_verified, + is_password_autoset=True, + ) + + user.set_password(uuid.uuid4().hex) + user.save() + + SocialLoginConnection.objects.update_or_create( + medium=medium, + extra_data={}, + user=user, + defaults={ + "token_data": {"id_token": id_token}, + "last_login_at": timezone.now(), + }, + ) + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + return Response(data, status=status.HTTP_201_CREATED) + + +class SignUpEndpoint(BaseAPIView): + permission_classes = (AllowAny,) + + def post(self, request): + email = request.data.get("email", False) + password = request.data.get("password", False) + confirm_password = request.data.get("confirm_password", False) + + if not password == confirm_password: + return Response( + {"message": "Passwords does not match."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Raise exception if any of the above are missing + if not email or not password: + return Response( + {"message": "Both email and password are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = email.strip().lower() + + try: + validate_email(email) + except ValidationError: + return Response( + {"message": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the user already exists + if User.objects.filter(email=email).exists(): + return Response( + {"message": "User with this email already exists."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create( + email=email, + username=email.split("@")[0], + password=make_password(password), + ) + + user.save() + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + return Response(data, status=status.HTTP_200_OK) + + +class SignInEndpoint(BaseAPIView): + permission_classes = (AllowAny,) + + def post(self, request): + email = request.data.get("email", False) + password = request.data.get("password", False) + + # Raise exception if any of the above are missing + if not email or not password: + return Response( + {"message": "Both email and password are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = email.strip().lower() + + try: + validate_email(email) + except ValidationError: + return Response( + {"message": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.filter(email=email).first() + + if user is None: + return Response( + { + "message": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + # Sign up Process + if not user.check_password(password): + return Response( + {"message": "Incorrect password."}, + status=status.HTTP_403_FORBIDDEN, + ) + if not user.is_active: + return Response( + { + "message": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + user.save() + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + return Response(data, status=status.HTTP_200_OK) + + +class SignOutEndpoint(BaseAPIView): + def post(self, request): + try: + refresh_token = request.data.get("refresh_token") + + if not refresh_token: + return Response( + {"message": "No refresh token provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + token = RefreshToken(refresh_token) + token.blacklist() + + return Response( + {"message": "Successfully logged out"}, status=status.HTTP_200_OK + ) + except Exception as e: + return Response( + {"message": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class RequestEmailVerificationEndpoint(BaseAPIView): + permission_classes = [ + IsAuthenticated, + ] + + def get(self, request): + refresh_token = RefreshToken.for_user(request.user) + token = str(refresh_token.access_token) + current_site = settings.WEB_URL + email_verification_task.delay( + request.user.first_name, request.user.email, token, current_site + ) + return Response( + {"message": "Email sent successfully."}, status=status.HTTP_200_OK + ) + + +class VerifyEmailEndpoint(BaseAPIView): + permission_classes = [ + IsAuthenticated, + ] + + def get(self, request): + token = request.GET.get("token") + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256") + user = User.objects.get(id=payload["user_id"]) + + if not user.is_email_verified: + user.is_email_verified = True + user.save() + return Response( + {"message": "Successfully activated."}, status=status.HTTP_200_OK + ) + else: + return Response( + {"message": "Email already verified."}, status=status.HTTP_200_OK + ) + except jwt.ExpiredSignatureError: + return Response( + {"message": "Activation expired."}, status=status.HTTP_400_BAD_REQUEST + ) + except jwt.exceptions.DecodeError: + return Response( + {"message": "Invalid token."}, status=status.HTTP_400_BAD_REQUEST + ) + + +class ForgotPasswordEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + email = request.data.get("email") + + try: + validate_email(email) + except ValidationError: + return Response( + {"message": "Please enter a valid email"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = generate_password_token(user=user) + # send the forgot password email + forgot_password_task.delay(user.first_name, user.email, uidb64, token) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + return Response( + {"message": "User with the provided email does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ResetPasswordEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request, uidb64, token): + try: + # Decode the id from the uidb64 + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(uuid=id) + + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): + return Response( + {"message": "Token is invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + # Reset the password + serializer = ResetPasswordSerializer(data=request.data) + if serializer.is_valid(): + # set_password also hashes the password that the user will get + user.set_password(serializer.data.get("new_password")) + user.is_password_autoset = False + user.save() + + # Log the user in + # Generate access token for the user + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + return Response(data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except DjangoUnicodeDecodeError as indentifier: + return Response( + {"message": "token is not valid, please check the new one"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + +class ChangePasswordEndpoint(BaseAPIView): + def post(self, request): + serializer = ChangePasswordSerializer(data=request.data) + user = User.objects.get(pk=request.user.id) + if serializer.is_valid(): + if not user.check_password(serializer.data.get("old_password")): + return Response( + {"message": "Old password is not correct"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # set_password also hashes the password that the user will get + user.set_password(serializer.data.get("new_password")) + user.is_password_autoset = False + user.save() + return Response( + {"message": "Password updated successfully"}, + status=status.HTTP_200_OK, + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class SetUserPasswordEndpoint(BaseAPIView): + def post(self, request): + user = User.objects.get(pk=request.user.id) + new_password = request.data.get("new_password", False) + confirm_password = request.data.get("confirm_password", False) + + if not new_password == confirm_password: + return Response( + {"message": "Passwords does not match."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If the user password is not autoset then return detail + if not user.is_password_autoset: + return Response( + { + "message": "Your password is already set please change your password from profile" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check password validation + if not new_password and len(str(new_password)) < 8: + return Response( + {"message": "Password is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Set the user password + user.set_password(new_password) + user.is_password_autoset = False + user.save() + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/djangoindia/api/views/base.py b/backend/djangoindia/api/views/base.py new file mode 100644 index 00000000..526fb822 --- /dev/null +++ b/backend/djangoindia/api/views/base.py @@ -0,0 +1,72 @@ +# Python imports +import zoneinfo + +from django_filters.rest_framework import DjangoFilterBackend + +# Third part imports +from rest_framework import status +from rest_framework.exceptions import APIException +from rest_framework.filters import SearchFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet + +# Django imports +from django.utils import timezone + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class BaseViewSet(TimezoneMixin, ModelViewSet): + model = None + + permission_classes = [ + IsAuthenticated, + ] + + filter_backends = ( + DjangoFilterBackend, + SearchFilter, + ) + + filterset_fields = [] + + search_fields = [] + + def get_queryset(self): + try: + return self.model.objects.all() + except Exception as e: + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + + +class BaseAPIView(TimezoneMixin, APIView): + permission_classes = [ + IsAuthenticated, + ] + + filter_backends = ( + DjangoFilterBackend, + SearchFilter, + ) + + filterset_fields = [] + + search_fields = [] + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset diff --git a/backend/djangoindia/api/views/user.py b/backend/djangoindia/api/views/user.py new file mode 100644 index 00000000..23616148 --- /dev/null +++ b/backend/djangoindia/api/views/user.py @@ -0,0 +1,36 @@ +# Module imports +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from djangoindia.api.serializers import UserMeSerializer, UserSerializer +from djangoindia.db.models import User + +from .base import BaseAPIView, BaseViewSet + + +class UserEndpoint(BaseViewSet): + serializer_class = UserSerializer + model = User + permission_classes = [IsAuthenticated] + + def get_object(self): + return self.request.user + + def retrieve(self, request): + serialized_data = UserMeSerializer(request.user).data + return Response( + serialized_data, + status=status.HTTP_200_OK, + ) + + def deactivate(self, request): + pass + + +class UpdateUserOnBoardedEndpoint(BaseAPIView): + def patch(self, request): + user = User.objects.get(pk=request.user.id, is_active=True) + user.is_onboarded = request.data.get("is_onboarded", False) + user.save() + return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) diff --git a/backend/djangoindia/bg_tasks/auth/__init__.py b/backend/djangoindia/bg_tasks/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/djangoindia/bg_tasks/auth/email_verification_task.py b/backend/djangoindia/bg_tasks/auth/email_verification_task.py new file mode 100644 index 00000000..0ed3b5c7 --- /dev/null +++ b/backend/djangoindia/bg_tasks/auth/email_verification_task.py @@ -0,0 +1,37 @@ +# Third party imports +from celery import shared_task + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + + +@shared_task +def email_verification_task(first_name, email, token, current_site): + try: + realtivelink = "/email-verify/" + "?token=" + str(token) + abs_url = current_site + realtivelink + + from_email_string = settings.DEFAULT_FROM_EMAIL + + subject = "Verify your Email!" + + context = { + "first_name": first_name, + "verification_url": abs_url, + } + + html_content = render_to_string("auth/email_verification.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) + return diff --git a/backend/djangoindia/bg_tasks/auth/forgot_password_task.py b/backend/djangoindia/bg_tasks/auth/forgot_password_task.py new file mode 100644 index 00000000..b6b78ca5 --- /dev/null +++ b/backend/djangoindia/bg_tasks/auth/forgot_password_task.py @@ -0,0 +1,45 @@ +# Django imports +# Third party imports +from celery import shared_task + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + + +@shared_task +def forgot_password_task(first_name, email, uidb64, token): + try: + current_site = settings.WEB_URL + + realtivelink = "/reset-password/?uidb64=" + uidb64 + "&token=" + token + abs_url = str(current_site) + realtivelink + from_email_string = settings.DEFAULT_FROM_EMAIL + + subject = "A new password to your Django India account has been requested" + + context = { + "first_name": first_name, + "forgot_password_url": abs_url, + "email": email, + } + + html_content = render_to_string("auth/forgot_password.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=from_email_string, + to=[email], + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) + return diff --git a/backend/djangoindia/db/admin.py b/backend/djangoindia/db/admin.py index 828d42cb..5f7a86e8 100644 --- a/backend/djangoindia/db/admin.py +++ b/backend/djangoindia/db/admin.py @@ -1,321 +1,345 @@ -# from django.conf import settings -from import_export import fields, resources -from import_export.admin import ImportExportModelAdmin -from import_export.widgets import ForeignKeyWidget - -from django.conf import settings -from django.contrib import admin, messages -from django.db import transaction -from django.db.models import Count, F -from django.shortcuts import redirect -from django.template.response import TemplateResponse -from django.urls import path - -from djangoindia.bg_tasks.event_registration import send_mass_mail_task -from djangoindia.db.models.communication import ContactUs, Subscriber -from djangoindia.db.models.event import Event, EventRegistration -from djangoindia.db.models.partner_and_sponsor import ( - CommunityPartner, - Sponsor, - Sponsorship, -) -from djangoindia.db.models.update import Update -from djangoindia.db.models.user import User -from djangoindia.db.models.volunteer import Volunteer - -from .forms import EmailForm, EventForm, UpdateForm - - -@admin.action(description="Send email to selected users") -def send_email_to_selected_users(modeladmin, request, queryset): - ids = queryset.values_list("id", flat=True) - return redirect(f'send_email/?ids={",".join(map(str, ids))}') - - -class SponsorInline(admin.TabularInline): - model = Sponsorship - extra = 1 - - -@admin.register(Event) -class EventAdmin(admin.ModelAdmin): - list_display = ("name", "city", "start_date", "event_mode", "created_at") - readonly_fields = ("created_at", "updated_at", "slug") - search_fields = ["name", "city"] - form = EventForm - inlines = [SponsorInline] - filter_horizontal = ("volunteers",) - - -class EventRegistrationResource(resources.ModelResource): - class Meta: - model = EventRegistration - - -@admin.register(EventRegistration) -class EventRegistrationAdmin(ImportExportModelAdmin): - list_display = ( - "event", - "first_name", - "email", - "created_at", - "attendee_type", - "first_time_attendee", - ) - readonly_fields = ("created_at", "updated_at", "first_time_attendee") - list_filter = ("event__name", "attendee_type", "first_time_attendee") - search_fields = [ - "email", - "event__name", - "first_name", - "last_name", - "first_time_attendee", - "attendee_type", - ] - raw_id_fields = ("event",) - actions = [send_email_to_selected_users] - resource_class = EventRegistrationResource - - def get_urls(self): - urls = super().get_urls() - custom_urls = [ - path( - "send_email/", - self.admin_site.admin_view(self.send_email_view), - name="send_email", - ), - ] - return custom_urls + urls - - @transaction.atomic - def delete_model(self, request, obj): - if obj.event.seats_left < obj.event.max_seats: - obj.event.seats_left += 1 - obj.event.save() - super().delete_model(request, obj) - - @transaction.atomic - def delete_queryset(self, request, queryset): - # Group registrations by event and count them - event_counts = queryset.values("event").annotate(count=Count("id")) - - # Update seats_left for each affected event - for event_count in event_counts: - Event.objects.filter(id=event_count["event"]).update( - seats_left=F("seats_left") + event_count["count"] - ) - - # Perform the actual deletion - super().delete_queryset(request, queryset) - - def send_email_view(self, request): - if request.method == "POST": - form = EmailForm(request.POST) - if form.is_valid(): - try: - subject = form.cleaned_data["subject"] - message = form.cleaned_data["message"] - emails = [] - from_email = settings.DEFAULT_FROM_EMAIL - - registration_ids = request.GET.get("ids").split(",") - queryset = EventRegistration.objects.filter(id__in=registration_ids) - - for registration in queryset: - recipient_email = registration.email - emails.append((subject, message, from_email, [recipient_email])) - - send_mass_mail_task(emails, fail_silently=False) - messages.success( - request, f"{len(emails)} emails sent successfully." - ) - return redirect("../") - except Exception as e: - messages.error(request, f"Error sending emails: {str(e)}") - else: - form = EmailForm() - - context = { - "form": form, - "opts": self.model._meta, - "queryset": request.GET.get("ids").split(","), - } - return TemplateResponse(request, "admin/send_email.html", context) - - -@admin.register(Subscriber) -class SubscriberAdmin(admin.ModelAdmin): - list_display = ("email", "created_at") - readonly_fields = ("created_at", "updated_at") - ordering = ("-created_at",) - search_fields = [ - "name", - "email", - ] - - -@admin.register(ContactUs) -class ContactUsAdmin(admin.ModelAdmin): - list_display = ("first_name", "email", "created_at") - readonly_fields = ("created_at", "updated_at") - ordering = ("-created_at",) - search_fields = [ - "email", - ] - - -class SponsorshipResource(resources.ModelResource): - sponsor_name = fields.Field( - column_name="sponsor_name", - attribute="sponsor_details", - widget=ForeignKeyWidget(Sponsor, "name"), - ) - sponsor_email = fields.Field( - column_name="sponsor_email", attribute="sponsor_details__email" - ) - sponsor_url = fields.Field( - column_name="sponsor_url", attribute="sponsor_details__url" - ) - - class Meta: - model = Sponsorship - fields = ( - "id", - "sponsor_name", - "sponsor_email", - "sponsor_url", - "tier", - "type", - "amount_inr", - "created_at", - "updated_at", - ) - export_order = fields - - -@admin.register(Sponsorship) -class SponsorshipAdmin(ImportExportModelAdmin): - list_display = ("sponsor_details", "tier", "type", "event") - list_filter = ("type", "event", "tier") - search_fields = [ - "sponsor_details__name", - ] - readonly_fields = ("created_at", "updated_at") - resource_class = SponsorshipResource - - def get_export_queryset(self, request): - return super().get_export_queryset(request).select_related("sponsor_details") - - -@admin.register(Sponsor) -class SponsorAdmin(admin.ModelAdmin): - list_display = ["name", "type", "email"] - search_fields = [ - "name", - ] - readonly_fields = ("created_at", "updated_at") - - -# email sending functionality and update registration -@admin.register(Update) -class UpdateAdmin(admin.ModelAdmin): - form = UpdateForm - list_display = ("email_subject", "type", "created_at", "mail_sent") - search_fields = ["email_subject", "type"] - readonly_fields = ("created_at", "updated_at") - actions = ["send_update"] - - @admin.action(description="Send selected updates to subscribers") - def send_update(self, request, queryset): - for update in queryset: - update.send_bulk_emails() - self.message_user(request, "Update emails sent.") - - -@admin.register(CommunityPartner) -class CommunityPartnerAdmin(admin.ModelAdmin): - list_display = [ - "name", - "website", - "contact_name", - "contact_email", - "contact_number", - "description", - ] - search_fields = ["name"] - readonly_fields = ("created_at", "updated_at") - - -class EventVolunteerResource(resources.ModelResource): - class Meta: - model = Volunteer - fields = ("id", "name", "about", "email", "twitter", " linkedin") - - -@admin.register(Volunteer) -class EventVolunteerAdmin(ImportExportModelAdmin): - list_display = ["name", "about", "email"] - search_fields = ["events__name", "name", "email"] - readonly_fields = ("created_at", "updated_at") - list_filter = ("events__name",) - resource_class = EventVolunteerResource - filter_horizontal = ("events",) - - -@admin.register(User) -class UserAdmin(admin.ModelAdmin): - list_display = ( - "username", - "email", - "first_name", - "last_name", - "is_active", - "is_superuser", - "is_email_verified", - ) - list_filter = ( - "is_active", - "is_staff", - "is_superuser", - "is_email_verified", - "gender", - ) - search_fields = ("username", "email", "first_name", "last_name") - readonly_fields = ("created_at", "updated_at") - filter_horizontal = ( - "groups", - "user_permissions", - ) - fieldsets = ( - (None, {"fields": ("username", "email", "password")}), - ( - "Personal info", - { - "fields": ( - "first_name", - "last_name", - "avatar", - "gender", - "organization", - "mobile_number", - ) - }, - ), - ( - "Permissions", - { - "fields": ( - "is_active", - "is_staff", - "is_superuser", - "groups", - "user_permissions", - "is_email_verified", - "is_password_expired", - "is_onboarded", - ), - }, - ), - ("Important dates", {"fields": ("created_at", "updated_at")}), - ) - ordering = ("-created_at",) +# from django.conf import settings +from import_export import fields, resources +from import_export.admin import ImportExportModelAdmin +from import_export.widgets import ForeignKeyWidget + +from django.conf import settings +from django.contrib import admin, messages +from django.db import transaction +from django.db.models import Count, F +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import path + +from djangoindia.bg_tasks.event_registration import send_mass_mail_task +from djangoindia.db.models import ( + CommunityPartner, + ContactUs, + Event, + EventRegistration, + EventUserRegistration, + SocialLoginConnection, + Sponsor, + Sponsorship, + Subscriber, + Update, + User, + Volunteer, +) + +from .forms import EmailForm, EventForm, UpdateForm + + +@admin.action(description="Send email to selected users") +def send_email_to_selected_users(modeladmin, request, queryset): + ids = queryset.values_list("id", flat=True) + return redirect(f'send_email/?ids={",".join(map(str, ids))}') + + +class SponsorInline(admin.TabularInline): + model = Sponsorship + extra = 1 + + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ("name", "city", "start_date", "event_mode", "created_at") + readonly_fields = ("created_at", "updated_at", "slug") + search_fields = ["name", "city"] + form = EventForm + inlines = [SponsorInline] + filter_horizontal = ("volunteers",) + + +class EventRegistrationResource(resources.ModelResource): + class Meta: + model = EventRegistration + + +@admin.register(EventRegistration) +class EventRegistrationAdmin(ImportExportModelAdmin): + list_display = ( + "event", + "first_name", + "email", + "created_at", + "attendee_type", + "first_time_attendee", + ) + readonly_fields = ("created_at", "updated_at", "first_time_attendee") + list_filter = ("event__name", "attendee_type", "first_time_attendee") + search_fields = [ + "email", + "event__name", + "first_name", + "last_name", + "first_time_attendee", + "attendee_type", + ] + raw_id_fields = ("event",) + actions = [send_email_to_selected_users] + resource_class = EventRegistrationResource + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "send_email/", + self.admin_site.admin_view(self.send_email_view), + name="send_email", + ), + ] + return custom_urls + urls + + @transaction.atomic + def delete_model(self, request, obj): + if obj.event.seats_left < obj.event.max_seats: + obj.event.seats_left += 1 + obj.event.save() + super().delete_model(request, obj) + + @transaction.atomic + def delete_queryset(self, request, queryset): + # Group registrations by event and count them + event_counts = queryset.values("event").annotate(count=Count("id")) + + # Update seats_left for each affected event + for event_count in event_counts: + Event.objects.filter(id=event_count["event"]).update( + seats_left=F("seats_left") + event_count["count"] + ) + + # Perform the actual deletion + super().delete_queryset(request, queryset) + + def send_email_view(self, request): + if request.method == "POST": + form = EmailForm(request.POST) + if form.is_valid(): + try: + subject = form.cleaned_data["subject"] + message = form.cleaned_data["message"] + emails = [] + from_email = settings.DEFAULT_FROM_EMAIL + + registration_ids = request.GET.get("ids").split(",") + queryset = EventRegistration.objects.filter(id__in=registration_ids) + + for registration in queryset: + recipient_email = registration.email + emails.append((subject, message, from_email, [recipient_email])) + + send_mass_mail_task(emails, fail_silently=False) + messages.success( + request, f"{len(emails)} emails sent successfully." + ) + return redirect("../") + except Exception as e: + messages.error(request, f"Error sending emails: {str(e)}") + else: + form = EmailForm() + + context = { + "form": form, + "opts": self.model._meta, + "queryset": request.GET.get("ids").split(","), + } + return TemplateResponse(request, "admin/send_email.html", context) + + +@admin.register(Subscriber) +class SubscriberAdmin(admin.ModelAdmin): + list_display = ("email", "created_at") + readonly_fields = ("created_at", "updated_at") + ordering = ("-created_at",) + search_fields = [ + "name", + "email", + ] + + +@admin.register(ContactUs) +class ContactUsAdmin(admin.ModelAdmin): + list_display = ("first_name", "email", "created_at") + readonly_fields = ("created_at", "updated_at") + ordering = ("-created_at",) + search_fields = [ + "email", + ] + + +class SponsorshipResource(resources.ModelResource): + sponsor_name = fields.Field( + column_name="sponsor_name", + attribute="sponsor_details", + widget=ForeignKeyWidget(Sponsor, "name"), + ) + sponsor_email = fields.Field( + column_name="sponsor_email", attribute="sponsor_details__email" + ) + sponsor_url = fields.Field( + column_name="sponsor_url", attribute="sponsor_details__url" + ) + + class Meta: + model = Sponsorship + fields = ( + "id", + "sponsor_name", + "sponsor_email", + "sponsor_url", + "tier", + "type", + "amount_inr", + "created_at", + "updated_at", + ) + export_order = fields + + +@admin.register(Sponsorship) +class SponsorshipAdmin(ImportExportModelAdmin): + list_display = ("sponsor_details", "tier", "type", "event") + list_filter = ("type", "event", "tier") + search_fields = [ + "sponsor_details__name", + ] + readonly_fields = ("created_at", "updated_at") + resource_class = SponsorshipResource + + def get_export_queryset(self, request): + return super().get_export_queryset(request).select_related("sponsor_details") + + +@admin.register(Sponsor) +class SponsorAdmin(admin.ModelAdmin): + list_display = ["name", "type", "email"] + search_fields = [ + "name", + ] + readonly_fields = ("created_at", "updated_at") + + +# email sending functionality and update registration +@admin.register(Update) +class UpdateAdmin(admin.ModelAdmin): + form = UpdateForm + list_display = ("email_subject", "type", "created_at", "mail_sent") + search_fields = ["email_subject", "type"] + readonly_fields = ("created_at", "updated_at") + actions = ["send_update"] + + @admin.action(description="Send selected updates to subscribers") + def send_update(self, request, queryset): + for update in queryset: + update.send_bulk_emails() + self.message_user(request, "Update emails sent.") + + +@admin.register(CommunityPartner) +class CommunityPartnerAdmin(admin.ModelAdmin): + list_display = [ + "name", + "website", + "contact_name", + "contact_email", + "contact_number", + "description", + ] + search_fields = ["name"] + readonly_fields = ("created_at", "updated_at") + + +class EventVolunteerResource(resources.ModelResource): + class Meta: + model = Volunteer + fields = ("id", "name", "about", "email", "twitter", " linkedin") + + +@admin.register(Volunteer) +class EventVolunteerAdmin(ImportExportModelAdmin): + list_display = ["name", "about", "email"] + search_fields = ["events__name", "name", "email"] + readonly_fields = ("created_at", "updated_at") + list_filter = ("events__name",) + resource_class = EventVolunteerResource + filter_horizontal = ("events",) + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ( + "username", + "email", + "first_name", + "last_name", + "is_active", + "is_superuser", + "is_email_verified", + ) + list_filter = ( + "is_active", + "is_staff", + "is_superuser", + "is_email_verified", + "gender", + ) + search_fields = ("username", "email", "first_name", "last_name") + readonly_fields = ("created_at", "updated_at") + filter_horizontal = ( + "groups", + "user_permissions", + ) + fieldsets = ( + (None, {"fields": ("username", "email", "password")}), + ( + "Personal info", + { + "fields": ( + "first_name", + "last_name", + "avatar", + "gender", + "organization", + "mobile_number", + ) + }, + ), + ( + "Permissions", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + "is_email_verified", + "is_password_expired", + "is_onboarded", + ), + }, + ), + ("Important dates", {"fields": ("created_at", "updated_at")}), + ) + ordering = ("-created_at",) + + +@admin.register(SocialLoginConnection) +class SocialLoginConnectionAdmin(admin.ModelAdmin): + list_display = ["user", "provider", "created_at"] + search_fields = ["user__username", "user__email"] + readonly_fields = ("created_at", "updated_at") + ordering = ("-created_at",) + + def provider(self, obj): + return obj.medium + + +@admin.register(EventUserRegistration) +class EventUserRegistrationAdmin(admin.ModelAdmin): + list_display = ["user", "event", "created_at"] + search_fields = ["user__username", "user__email", "event__name"] + readonly_fields = ("created_at", "updated_at") + list_filter = ("event__name",) + ordering = ("-created_at",) diff --git a/backend/djangoindia/db/migrations/0002_alter_user_avatar_alter_user_gender_and_more.py b/backend/djangoindia/db/migrations/0002_alter_user_avatar_alter_user_gender_and_more.py new file mode 100644 index 00000000..b5b1db47 --- /dev/null +++ b/backend/djangoindia/db/migrations/0002_alter_user_avatar_alter_user_gender_and_more.py @@ -0,0 +1,128 @@ +# Generated by Django 4.2.5 on 2025-01-10 15:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="avatar", + field=models.ImageField(upload_to="users/avatars/"), + ), + migrations.AlterField( + model_name="user", + name="gender", + field=models.CharField( + choices=[ + ("male", "male"), + ("female", "female"), + ("not_to_specify", "not_to_specify"), + ], + max_length=50, + ), + ), + migrations.CreateModel( + name="SocialLoginConnection", + fields=[ + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "medium", + models.CharField( + choices=[("Google", "google")], default=None, max_length=20 + ), + ), + ( + "last_login_at", + models.DateTimeField(default=django.utils.timezone.now, null=True), + ), + ( + "last_received_at", + models.DateTimeField(default=django.utils.timezone.now, null=True), + ), + ("token_data", models.JSONField(null=True)), + ("extra_data", models.JSONField(null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_login_connections", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Social Login Connection", + "verbose_name_plural": "Social Login Connections", + "db_table": "social_login_connections", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="EventUserRegistration", + fields=[ + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "status", + models.CharField( + choices=[ + ("going", "going"), + ("waiting", "waiting"), + ("not_going", "not_going"), + ], + max_length=50, + ), + ), + ("first_time_attendee", models.BooleanField(default=True)), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="db.event" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/backend/djangoindia/db/models/__init__.py b/backend/djangoindia/db/models/__init__.py index beb172b5..99581aa2 100644 --- a/backend/djangoindia/db/models/__init__.py +++ b/backend/djangoindia/db/models/__init__.py @@ -1,10 +1,9 @@ -from djangoindia.db.models.communication import ContactUs, Subscriber -from djangoindia.db.models.event import Event, EventRegistration -from djangoindia.db.models.partner_and_sponsor import CommunityPartner, Sponsorship -from djangoindia.db.models.update import Update -from djangoindia.db.models.volunteer import Volunteer - -from .user import User +from .communication import ContactUs, Subscriber +from .event import Event, EventRegistration, EventUserRegistration +from .partner_and_sponsor import CommunityPartner, Sponsor, Sponsorship +from .update import Update +from .user import SocialLoginConnection, User +from .volunteer import Volunteer __all__ = [ @@ -17,4 +16,7 @@ "Update", "Volunteer", "User", + "SocialLoginConnection", + "Sponsor", + "EventUserRegistration", ] diff --git a/backend/djangoindia/db/models/event.py b/backend/djangoindia/db/models/event.py index 489944d4..e36e048d 100644 --- a/backend/djangoindia/db/models/event.py +++ b/backend/djangoindia/db/models/event.py @@ -1,5 +1,6 @@ from cabinet.models import Folder +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone @@ -45,6 +46,7 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) +# Deprecated model class EventRegistration(BaseModel): class ProfessionalStatus(models.TextChoices): WORKING_PROFESSIONAL = "working_professional" @@ -117,3 +119,32 @@ def __str__(self) -> str: return ( f"{self.first_name} {self.last_name} ({self.email}) --- {self.event.name}" ) + + +class EventUserRegistration(BaseModel): + class RegistrationStatusType: + CHOICES = ( + ("going", "going"), + ("waiting", "waiting"), + ("not_going", "not_going"), + ) + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + event = models.ForeignKey(Event, on_delete=models.CASCADE) + status = models.CharField(choices=RegistrationStatusType.CHOICES, max_length=50) + first_time_attendee = models.BooleanField(default=True) + + def save(self, *args, **kwargs): + # This is a new registration + if self._state.adding: + user_has_registered_before = EventUserRegistration.objects.filter( + user=self.user + ).exists() + self.first_time_attendee = not user_has_registered_before + + if self.event.seats_left > 0: + self.event.seats_left -= 1 + self.event.save() + else: + raise ValueError("No seats left for this event.") + super().save(*args, **kwargs) diff --git a/backend/djangoindia/db/models/user.py b/backend/djangoindia/db/models/user.py index 46b959da..10d2e16a 100644 --- a/backend/djangoindia/db/models/user.py +++ b/backend/djangoindia/db/models/user.py @@ -3,6 +3,9 @@ import pytz +from django.conf import settings + +# Django imports from django.contrib.auth.models import ( AbstractBaseUser, Group, @@ -10,18 +13,18 @@ PermissionsMixin, UserManager, ) - -# Django imports from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from .base import BaseModel + class User(AbstractBaseUser, PermissionsMixin): class GENDER: CHOICES = ( ("male", "male"), ("female", "female"), - ("non-binary", "non-binary"), ("not_to_specify", "not_to_specify"), ) @@ -35,7 +38,7 @@ class GENDER: email = models.CharField(max_length=255, null=True, blank=True, unique=True) first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) - avatar = models.CharField(max_length=255, blank=True) + avatar = models.ImageField(upload_to="users/avatars/") organization = models.CharField(max_length=500, blank=True, null=True) gender = models.CharField(choices=GENDER.CHOICES, max_length=50) @@ -98,3 +101,30 @@ def save(self, *args, **kwargs): self.is_staff = True super().save(*args, **kwargs) + + +class SocialLoginConnection(BaseModel): + medium = models.CharField( + max_length=20, + choices=(("Google", "google"),), + default=None, + ) + last_login_at = models.DateTimeField(default=timezone.now, null=True) + last_received_at = models.DateTimeField(default=timezone.now, null=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="user_login_connections", + ) + token_data = models.JSONField(null=True) + extra_data = models.JSONField(null=True) + + class Meta: + verbose_name = "Social Login Connection" + verbose_name_plural = "Social Login Connections" + db_table = "social_login_connections" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the user and medium""" + return f"{self.medium} >" diff --git a/backend/djangoindia/settings/base.py b/backend/djangoindia/settings/base.py index 8701ae5e..9556f90c 100644 --- a/backend/djangoindia/settings/base.py +++ b/backend/djangoindia/settings/base.py @@ -12,6 +12,7 @@ import os +from datetime import timedelta from pathlib import Path from dotenv import load_dotenv @@ -53,6 +54,7 @@ "djangoindia.bg_tasks", "djangoindia.db", "rest_framework", + "rest_framework_simplejwt.token_blacklist", "drf_spectacular", "import_export", "cabinet", @@ -69,6 +71,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "crum.CurrentRequestUserMiddleware", ] ROOT_URLCONF = "djangoindia.urls" @@ -168,6 +171,15 @@ CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND") REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } @@ -183,3 +195,33 @@ FILE_UPLOAD_MAX_MEMORY_SIZE = 104857600 # 100 mb DATA_UPLOAD_MAX_MEMORY_SIZE = 104857600 # 100 mb + +# JWT Auth Configuration +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=7), + "REFRESH_TOKEN_LIFETIME": timedelta(days=43200), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": None, + "AUDIENCE": None, + "ISSUER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), +} + +WEB_URL = os.environ.get("NEXT_PUBLIC_FRONTEND_URL", "http://localhost:3000") diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index 6f8aa84d..fd983e4c 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -3,12 +3,17 @@ celery==5.3.4 Django==4.2.5 django-cabinet==0.17.0 django-cors-headers==4.3.1 # https://github.com/adamchainz/django-cors-headers +django-crum==0.7.9 +django-filter==23.5 django-import-export==4.1.1 django-import-export[all] django-prose-editor==0.9.0 -django-prose-editor[sanitize] +django-prose-editor[sanitize] djangorestframework==3.14.0 # https://github.com/encode/django-rest-framework +djangorestframework-simplejwt==5.3.0 drf-spectacular +google-api-python-client==2.97.0 +google-auth==2.22.0 Pillow==10.4.0 psycopg2-binary==2.9.9 python-dotenv==1.0.1 diff --git a/backend/templates/auth/email_verification.html b/backend/templates/auth/email_verification.html new file mode 100644 index 00000000..3d65ba04 --- /dev/null +++ b/backend/templates/auth/email_verification.html @@ -0,0 +1,97 @@ + + + + + Email Verification - Django India + + + + +
+ OD +

Email Verification

+ {% if first_name %} +

Hi {{first_name}}!

+ {% else %} +

Hi there!

+ {% endif %} +

Thank you for joining us at Django India. We're excited to have you on board! Before you get started, please + verify your email address by clicking the button below:

+ Verify Email +

If you did not sign up for an account at Django India, please disregard this email.

+

Best regards,
The Django India Team

+
+ + + diff --git a/backend/templates/auth/forgot_password.html b/backend/templates/auth/forgot_password.html new file mode 100644 index 00000000..3893e06c --- /dev/null +++ b/backend/templates/auth/forgot_password.html @@ -0,0 +1,103 @@ + + + + + Password Reset - Django India + + + + +
+ OD +

Password Reset

+ {% if first_name %} +

Hi {{first_name}}!

+ {% else %} +

Hi there!

+ {% endif %} +

We have received a request to reset your password for your account at Django India. Don't worry, we've got you + covered! Just click the button below to reset your password and get back to using our awesome services:

+ Reset Password +
+

Stay Secure

+

Your security is our top priority. We recommend choosing a strong, unique password and keeping it confidential. + Avoid using common words or easily guessable information to protect your account from unauthorized access.

+
+

If you did not request a password reset, please disregard this email.

+

Thank you for being a valued member of Django India Community. We appreciate your continued support!

+

Best regards,
The Django India Team

+
+ + + diff --git a/contributing.md b/contributing.md index ca45f57b..c8267c30 100644 --- a/contributing.md +++ b/contributing.md @@ -104,7 +104,7 @@ Remember, every contribution, no matter how small, is valuable and appreciated! 2. **Clone the repository:** ``` - git clone https://github.com//djangoindia.org + git clone https://github.com/djangoindia/djangoindia.org.git ``` ### Without Docker