mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
547 lines
21 KiB
Python
547 lines
21 KiB
Python
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
|
|
|
|
|
|
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:
|
|
following = request.user.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<username>[^/.]+)",
|
|
)
|
|
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:
|
|
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=request.user,
|
|
text=text,
|
|
)
|
|
|
|
if profile_owner != request.user:
|
|
Notification.objects.create(
|
|
recipient=profile_owner,
|
|
actor=request.user,
|
|
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<comment_id>[^/.]+)",
|
|
permission_classes=[permissions.IsAuthenticated],
|
|
)
|
|
def delete_comment(self, request: Request, pk=None, comment_id=None) -> Response:
|
|
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 != request.user
|
|
and not request.user.is_superuser
|
|
and profile_owner != request.user
|
|
):
|
|
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:
|
|
log_user_ip(request.user, request)
|
|
return Response(self.get_serializer(request.user).data)
|
|
|
|
@action(detail=False, methods=["patch"])
|
|
def update_preferences(self, request: Request) -> Response:
|
|
user = request.user
|
|
theme = request.data.get("theme")
|
|
language = request.data.get("language")
|
|
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 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,
|
|
"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 = request.user
|
|
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 = request.user
|
|
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 = request.user
|
|
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:
|
|
user_to_follow = self.get_object()
|
|
if user_to_follow == request.user:
|
|
return Response(
|
|
{"error": "You cannot follow yourself"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not request.user.following.filter(pk=user_to_follow.pk).exists():
|
|
request.user.following.add(user_to_follow)
|
|
Notification.objects.create(
|
|
recipient=user_to_follow,
|
|
actor=request.user,
|
|
notification_type="follow",
|
|
)
|
|
|
|
return Response({"status": "followed"})
|
|
|
|
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
|
def unfollow(self, request: Request, pk=None) -> Response:
|
|
user_to_unfollow = self.get_object()
|
|
request.user.following.remove(user_to_unfollow)
|
|
return Response({"status": "unfollowed"})
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def confirm_email(self, request: Request) -> Response:
|
|
user = request.user
|
|
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.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 = request.user
|
|
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 = request.user
|
|
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 = request.user
|
|
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):
|
|
return self.request.user.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:
|
|
request.user.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"})
|