import secrets import string from django.conf import settings from django.utils.decorators import method_decorator from django_ratelimit.decorators import ratelimit from rest_framework import permissions, status, viewsets from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response from api.models import Banner, Flavor, Notification, ProfileComment, User from api.serializers import ( FlavorSerializer, NotificationSerializer, ProfileCommentSerializer, RatingSerializer, UserSerializer, ) from api.services.ip_logging import log_user_ip from api.tasks import send_email_task from api.utils.auth import current_user def _generate_code() -> str: return "".join(secrets.choice(string.digits) for _ in range(6)) class UserViewSet(viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [permissions.IsAuthenticated] def get_permissions(self): if self.action in [ "signup", "verify_signup", "request_password_reset", "complete_password_reset", ]: return [permissions.AllowAny()] return super().get_permissions() @action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated]) def following_list(self, request: Request) -> Response: me = current_user(request) following = me.following.all() return Response(self.get_serializer(following, many=True).data) @action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny]) @method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True)) def signup(self, request: Request) -> Response: username = request.data.get("username") email = request.data.get("email") password = request.data.get("password") if not username or not email or not password: return Response( {"error": "Username, email and password required"}, status=status.HTTP_400_BAD_REQUEST, ) if User.objects.filter(username=username).exists(): return Response( {"error": "Username already exists"}, status=status.HTTP_400_BAD_REQUEST ) if User.objects.filter(email=email).exists(): return Response({"error": "Email already exists"}, status=status.HTTP_400_BAD_REQUEST) code = _generate_code() user = User.objects.create_user( username=username, email=email, password=password, is_active=False, email_confirmation_code=code, ) verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}" try: send_email_task.delay( subject="Verify your Holy Flavors account", message=( f"Hi {username},\n\nYour verification code is: {code}\n\n" f"Alternatively, you can complete your registration by clicking the link below:\n" f"{verification_link}\n\nWelcome to the archive!" ), recipient_list=[email], from_email=settings.DEFAULT_FROM_EMAIL, ) except Exception as e: user.delete() return Response( { "error": ( f"Failed to send verification email. Please check your SMTP settings " f"in the .env file. The server error was: {e}" ) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return Response( { "status": "User created, please verify your email", "username": username, "email": email, }, status=status.HTTP_201_CREATED, ) @action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny]) def resend_verification(self, request: Request) -> Response: username = request.data.get("username") if not username: return Response({"error": "Username required"}, status=status.HTTP_400_BAD_REQUEST) try: user = User.objects.get(username=username, is_active=False) except User.DoesNotExist: return Response( {"error": "User not found or already active"}, status=status.HTTP_404_NOT_FOUND, ) code = _generate_code() user.email_confirmation_code = code user.save() verification_link = ( f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}" ) try: send_email_task.delay( subject="Verify your Holy Flavors account", message=( f"Hi {user.username},\n\nYour new verification code is: {code}\n\n" f"Alternatively, you can complete your registration by clicking the link below:\n" f"{verification_link}" ), recipient_list=[user.email], from_email=settings.DEFAULT_FROM_EMAIL, ) except Exception as e: return Response( {"error": f"Failed to send email: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return Response({"status": "Verification code resent!"}) @action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny]) def verify_signup(self, request: Request) -> Response: username = request.data.get("username") code = request.data.get("code") try: user = User.objects.get(username=username, is_active=False) except User.DoesNotExist: return Response( {"error": "User not found or already active"}, status=status.HTTP_404_NOT_FOUND, ) if user.email_confirmation_code == code: user.is_active = True user.email_confirmation_code = None user.save() return Response({"status": "Account verified successfully!"}) return Response({"error": "Invalid verification code"}, status=status.HTTP_400_BAD_REQUEST) @action( detail=False, methods=["get"], permission_classes=[permissions.AllowAny], url_path="profile/(?P[^/.]+)", ) def public_profile(self, request: Request, username=None) -> Response: try: user = User.objects.get(username=username) except User.DoesNotExist: return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) ratings = user.ratings.select_related("flavor", "flavor__category").order_by("-score") comments = user.profile_comments.select_related("author").order_by("-created_at") followers = user.followers.all() following = user.following.all() return Response( { "id": user.id, "username": user.username, "theme": user.theme, "avatar": (request.build_absolute_uri(user.avatar.url) if user.avatar else None), "following_count": following.count(), "followers_count": followers.count(), "is_following": ( request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False ), "ratings": RatingSerializer(ratings, many=True, context={"request": request}).data, "comments": ProfileCommentSerializer( comments, many=True, context={"request": request} ).data, "followers": UserSerializer( followers, many=True, context={"request": request} ).data, "following": UserSerializer( following, many=True, context={"request": request} ).data, } ) @action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated]) def add_comment(self, request: Request, pk=None) -> Response: me = current_user(request) profile_owner = self.get_object() text = request.data.get("text") if not text: return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST) comment = ProfileComment.objects.create( profile_owner=profile_owner, author=me, text=text, ) if profile_owner != me: Notification.objects.create( recipient=profile_owner, actor=me, notification_type="profile_comment", profile_comment=comment, ) return Response( ProfileCommentSerializer(comment, context={"request": request}).data, status=status.HTTP_201_CREATED, ) @action( detail=True, methods=["delete"], url_path="delete_comment/(?P[^/.]+)", permission_classes=[permissions.IsAuthenticated], ) def delete_comment(self, request: Request, pk=None, comment_id=None) -> Response: me = current_user(request) profile_owner = self.get_object() try: comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner) except ProfileComment.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) if comment.author != me and not me.is_superuser and profile_owner != me: return Response(status=status.HTTP_403_FORBIDDEN) comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=False, methods=["get"]) def me(self, request: Request) -> Response: me = current_user(request) log_user_ip(me, request) return Response(self.get_serializer(me).data) @action(detail=False, methods=["patch"]) def update_preferences(self, request: Request) -> Response: user = current_user(request) theme = request.data.get("theme") language = request.data.get("language") drawer_anchor = request.data.get("drawer_anchor") banner_id = request.data.get("selected_banner") updated = False if theme in dict(User.THEME_CHOICES): user.theme = theme updated = True if language in dict(User.LANGUAGE_CHOICES): user.language = language updated = True if drawer_anchor in dict(User.DRAWER_ANCHOR_CHOICES): user.drawer_anchor = drawer_anchor updated = True if banner_id is not None: if banner_id == "": user.selected_banner = None updated = True else: try: banner = Banner.objects.get(pk=banner_id, is_enabled=True) user.selected_banner = banner updated = True except Banner.DoesNotExist: return Response( {"error": "Invalid or disabled banner"}, status=status.HTTP_400_BAD_REQUEST, ) if updated: user.save() return Response( { "status": "preferences updated", "theme": user.theme, "language": user.language, "drawer_anchor": user.drawer_anchor, "selected_banner": (user.selected_banner.id if user.selected_banner else None), } ) return Response({"error": "No valid updates provided"}, status=status.HTTP_400_BAD_REQUEST) @action(detail=False, methods=["post"]) def update_avatar(self, request: Request) -> Response: user = current_user(request) avatar = request.FILES.get("avatar") if not avatar: return Response({"error": "No avatar provided"}, status=status.HTTP_400_BAD_REQUEST) if avatar.size > 2 * 1024 * 1024: return Response( {"error": "File size exceeds 2MB limit"}, status=status.HTTP_400_BAD_REQUEST, ) user.avatar = avatar user.save() return Response(self.get_serializer(user).data) @action(detail=False, methods=["post"]) def change_password(self, request: Request) -> Response: user = current_user(request) old_password = request.data.get("old_password") new_password = request.data.get("new_password") if not user.check_password(old_password): return Response({"error": "Wrong old password"}, status=status.HTTP_400_BAD_REQUEST) user.set_password(new_password) user.save() return Response({"status": "password changed"}) @action(detail=False, methods=["patch"]) def update_profile(self, request: Request) -> Response: user = current_user(request) username = request.data.get("username") email = request.data.get("email") message = "" if username and username != user.username: if User.objects.filter(username=username).exists(): return Response( {"error": "Username already taken"}, status=status.HTTP_400_BAD_REQUEST, ) user.username = username if email and email != user.email: code = _generate_code() user.pending_email = email user.email_confirmation_code = code send_email_task.delay( subject="Confirm your new email", message=f"Hi {user.username},\n\nYour confirmation code is: {code}", recipient_list=[email], from_email=settings.DEFAULT_FROM_EMAIL, ) message = " Confirmation code sent to new email." user.save() data = self.get_serializer(user).data if message: data["message"] = message return Response(data) @action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated]) def follow(self, request: Request, pk=None) -> Response: me = current_user(request) user_to_follow = self.get_object() if user_to_follow == me: return Response( {"error": "You cannot follow yourself"}, status=status.HTTP_400_BAD_REQUEST, ) if not me.following.filter(pk=user_to_follow.pk).exists(): me.following.add(user_to_follow) Notification.objects.create( recipient=user_to_follow, actor=me, notification_type="follow", ) return Response({"status": "followed"}) @action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated]) def unfollow(self, request: Request, pk=None) -> Response: me = current_user(request) user_to_unfollow = self.get_object() me.following.remove(user_to_unfollow) return Response({"status": "unfollowed"}) @action(detail=False, methods=["post"]) def confirm_email(self, request: Request) -> Response: user = current_user(request) code = request.data.get("code") if not user.email_confirmation_code or code != user.email_confirmation_code: return Response( {"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST ) if not user.pending_email: return Response( {"error": "No pending email change"}, status=status.HTTP_400_BAD_REQUEST ) user.email = user.pending_email user.pending_email = None user.email_confirmation_code = None user.save() return Response({"status": "Email confirmed", "email": user.email}) @action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny]) @method_decorator(ratelimit(key="ip", rate="3/h", method="POST", block=True)) def request_password_reset(self, request: Request) -> Response: email = request.data.get("email") try: user = User.objects.get(email=email, is_active=True) except User.DoesNotExist: return Response( {"status": "If an account exists with this email, a reset code has been sent."} ) code = _generate_code() user.email_confirmation_code = code user.save() send_email_task.delay( subject="Password Reset Request", message=f"Hi {user.username},\n\nYour password reset code is: {code}", recipient_list=[email], from_email=settings.DEFAULT_FROM_EMAIL, ) return Response( {"status": "If an account exists with this email, a reset code has been sent."} ) @action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny]) @method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True)) def complete_password_reset(self, request: Request) -> Response: email = request.data.get("email") code = request.data.get("code") new_password = request.data.get("password") try: user = User.objects.get(email=email, email_confirmation_code=code) except User.DoesNotExist: return Response({"error": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST) user.set_password(new_password) user.email_confirmation_code = None user.save() return Response({"status": "Password reset successful!"}) @action(detail=False, methods=["post"]) @method_decorator(ratelimit(key="user", rate="3/h", method="POST", block=True)) def request_account_deletion(self, request: Request) -> Response: code = _generate_code() user = current_user(request) user.email_confirmation_code = code user.save() send_email_task.delay( subject="Confirm Account Deletion - Holy Flavors Archive", message=( f"Hi {user.username},\n\nYou requested to delete your account. " f"This action is permanent.\n\nYour deletion code is: {code}" ), recipient_list=[user.email], from_email=settings.DEFAULT_FROM_EMAIL, ) return Response({"status": "Code sent to your email"}) @action(detail=False, methods=["post"]) def confirm_account_deletion(self, request: Request) -> Response: user = current_user(request) code = request.data.get("code") if not user.email_confirmation_code or code != user.email_confirmation_code: return Response( {"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST ) user.delete() return Response({"status": "Account deleted"}) @action(detail=False, methods=["get"]) def dashboard(self, request: Request) -> Response: from django.db.models import Avg, Q user = current_user(request) rated_ids = user.ratings.values_list("flavor_id", flat=True) followed_users = user.following.all() missing_flavors = ( Flavor.objects.exclude(id__in=rated_ids) .exclude(category__name="Packs and other") .select_related("category") .annotate( average_rating=Avg("ratings__score"), followed_average_rating=Avg( "ratings__score", filter=Q(ratings__user__in=followed_users) ), ) .order_by("category__name", "name") ) rated_flavors = ( user.ratings.select_related("flavor", "flavor__category") .prefetch_related("replies", "replies__user") .order_by("-created_at") ) return Response( { "user": UserSerializer(user).data, "rated_count": rated_flavors.count(), "missing_count": missing_flavors.count(), "missing_flavors": FlavorSerializer( missing_flavors, many=True, context={"request": request} ).data, "my_ratings": RatingSerializer( rated_flavors, many=True, context={"request": request} ).data, } ) class NotificationViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = NotificationSerializer permission_classes = [permissions.IsAuthenticated] pagination_class = None def get_queryset(self): if getattr(self, "swagger_fake_view", False): return Notification.objects.none() me = current_user(self.request) return me.notifications.select_related( "actor", "rating__flavor", "reply__rating__flavor" ).order_by("-created_at") @action(detail=False, methods=["post"]) def mark_all_read(self, request: Request) -> Response: me = current_user(request) me.notifications.filter(is_read=False).update(is_read=True) return Response({"status": "all read"}) @action(detail=True, methods=["post"]) def mark_read(self, request: Request, pk=None) -> Response: notification = self.get_object() notification.is_read = True notification.save() return Response({"status": "marked read"})