diff --git a/backend/back_app/library/serializers.py b/backend/back_app/library/serializers.py index 9d56ea4..478d8c2 100644 --- a/backend/back_app/library/serializers.py +++ b/backend/back_app/library/serializers.py @@ -1,4 +1,7 @@ +import datetime + from library.models import Book, Category +from library.services import scrape_book_info from rest_framework import serializers @@ -39,3 +42,15 @@ def update(self, instance, validated_data): class BookInfoScrapeSerializer(serializers.Serializer): url = serializers.URLField(required=True) + + def validate_url(self, value): + return value + + def create(self, validated_data): + url = validated_data.get("url") + try: + book, created = scrape_book_info(url) + except Exception as e: + raise serializers.ValidationError(f"Error scraping book: {str(e)}") + self.created = created + return book diff --git a/backend/back_app/library/services.py b/backend/back_app/library/services.py new file mode 100644 index 0000000..3921399 --- /dev/null +++ b/backend/back_app/library/services.py @@ -0,0 +1,33 @@ +import datetime +import requests +from bs4 import BeautifulSoup +from library.models import Book, Category + +def scrape_book_info(url): + try: + resp = requests.get(url) + resp.raise_for_status() + except Exception as e: + raise Exception(f"Error querying URL: {str(e)}") + + soup = BeautifulSoup(resp.text, "html.parser") + title_tag = soup.find("h1") + meta_description = soup.find("meta", attrs={"name": "description"}) + scraped_title = title_tag.text.strip() if title_tag else "Unknown" + scraped_description = ( + meta_description.get("content", "").strip() + if meta_description and meta_description.get("content") + else "" + ) + + default_category, _ = Category.objects.get_or_create(name="Scraped") + book, created = Book.objects.update_or_create( + title=scraped_title, + category=default_category, + defaults={ + "author": "Unknown", + "publication_date": datetime.date.today(), + "description": scraped_description, + }, + ) + return book, created diff --git a/backend/back_app/library/views.py b/backend/back_app/library/views.py index 2182ead..f402a2e 100644 --- a/backend/back_app/library/views.py +++ b/backend/back_app/library/views.py @@ -1,10 +1,7 @@ -import datetime import io import logging import pandas as pd -import requests -from bs4 import BeautifulSoup from django.db.models.functions import ExtractYear from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend @@ -86,50 +83,11 @@ class ScrapeBookInfoView(generics.GenericAPIView): permission_classes = [permissions.IsAdminUser] serializer_class = BookInfoScrapeSerializer - @swagger_auto_schema( - operation_description="Scraping book information by URL and saving (or updating) it in the database", - request_body=BookInfoScrapeSerializer, - responses={200: "Scraped book info and saved/updated in DB"}, - ) def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - url = serializer.validated_data.get("url") - - try: - resp = requests.get(url) - resp.raise_for_status() - except Exception as e: - logger.error(f"Error querying URL {url}: {e}") - return Response( - {"detail": f"Error querying URL: {str(e)}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - soup = BeautifulSoup(resp.text, "html.parser") - title_tag = soup.find("h1") - meta_description = soup.find("meta", attrs={"name": "description"}) - scraped_title = title_tag.text.strip() if title_tag else "Unknown" - scraped_description = ( - meta_description["content"].strip() - if meta_description and meta_description.get("content") - else "" - ) - - default_category, _ = Category.objects.get_or_create(name="Scraped") - - book, created = Book.objects.update_or_create( - title=scraped_title, - category=default_category, - defaults={ - "author": "Unknown", - "publication_date": datetime.date.today(), - "description": scraped_description, - }, - ) - action = "created" if created else "updated" - logger.info(f"Book '{scraped_title}' {action} successfully.") - + book = serializer.save() + action = "created" if serializer.created else "updated" book_serializer = BookSerializer(book) return Response( {"detail": f"Book {action} successfully.", "book": book_serializer.data}, diff --git a/backend/back_app/users/migrations/0002_user_extra_fields.py b/backend/back_app/users/migrations/0002_user_extra_fields.py new file mode 100644 index 0000000..fcc4954 --- /dev/null +++ b/backend/back_app/users/migrations/0002_user_extra_fields.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 08:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="extra_fields", + field=models.JSONField(default=dict), + ), + ] diff --git a/backend/back_app/users/models.py b/backend/back_app/users/models.py index e8dc67b..0533455 100644 --- a/backend/back_app/users/models.py +++ b/backend/back_app/users/models.py @@ -5,7 +5,6 @@ class UserManager(BaseUserManager): - def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError("The Email field must be set") @@ -19,11 +18,6 @@ def create_superuser(self, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) - if extra_fields.get("is_staff") is not True: - raise ValueError("Superuser must have is_staff=True.") - if extra_fields.get("is_superuser") is not True: - raise ValueError("Superuser must have is_superuser=True.") - return self.create_user(email, password, **extra_fields) @@ -31,6 +25,7 @@ class User(AbstractUser): email = models.EmailField(unique=True) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) username = models.CharField(max_length=15, unique=True) + extra_fields = models.JSONField(default=dict) USERNAME_FIELD = "username" EMAIL_FIELD = "email" diff --git a/backend/back_app/users/permissions.py b/backend/back_app/users/permissions.py index fd84e2b..06a9349 100644 --- a/backend/back_app/users/permissions.py +++ b/backend/back_app/users/permissions.py @@ -4,4 +4,4 @@ class IsOwnerOrAdmin(BasePermission): def has_object_permission(self, request, view, obj): - return request.user and (request.user.is_staff or obj.id == request.user.id) + return obj == request.user or request.user.is_staff diff --git a/backend/back_app/users/serializers.py b/backend/back_app/users/serializers.py index 6948904..cb2bd06 100644 --- a/backend/back_app/users/serializers.py +++ b/backend/back_app/users/serializers.py @@ -1,29 +1,33 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.core.mail import send_mail +from django.utils.encoding import force_bytes, force_str +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from rest_framework import serializers from users.models import User +User = get_user_model() + class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["id", "username", "email"] + fields = ["id", "username", "email", "extra_fields", "is_staff", "is_superuser"] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get("request") + if request and not request.user.is_superuser: + self.fields.pop("is_staff", None) + self.fields.pop("is_superuser", None) + self.fields.pop("extra_fields", None) -class PasswordResetRequestSerializer(serializers.Serializer): - email = serializers.EmailField() - - def validate_email(self, value): - if not User.objects.filter(email=value).exists(): - raise serializers.ValidationError("User with this email does not exist") - return value - - -class PasswordResetConfirmSerializer(serializers.Serializer): - new_password = serializers.CharField(write_only=True) - - def validate_new_password(self, value): - if len(value) < 6: - raise serializers.ValidationError("Password too short") - return value + def update(self, instance, validated_data): + instance.username = validated_data.get("username", instance.username) + instance.email = validated_data.get("email", instance.email) + instance.save() + return instance class UserRegistrationSerializer(serializers.ModelSerializer): @@ -43,3 +47,49 @@ def create(self, validated_data): validated_data.pop("password2") user = User.objects.create_user(**validated_data) return user + + +class PasswordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField() + + def validate_email(self, value): + if not User.objects.filter(email=value).exists(): + raise serializers.ValidationError("User with this email does not exist") + return value + + def send_reset_email(self): + email = self.validated_data["email"] + user = User.objects.get(email=email) + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + reset_url = ( + f"{settings.URL_NGROK_HOST}/users/reset-password-confirm/{uid}/{token}/" + ) + send_mail( + subject="Password Reset Request", + message=f"Use the link to reset your password: {reset_url}", + from_email=None, + recipient_list=[email], + ) + return reset_url + + +class PasswordResetConfirmSerializer(serializers.Serializer): + new_password = serializers.CharField(write_only=True) + + def validate(self, attrs): + uidb64 = self.context.get("uidb64") + token = self.context.get("token") + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + self.user = User.objects.get(pk=uid) + except (TypeError, ValueError, User.DoesNotExist): + raise serializers.ValidationError("Invalid user") + + if not default_token_generator.check_token(self.user, token): + raise serializers.ValidationError("Invalid or expired token") + return attrs + + def save(self): + self.user.set_password(self.validated_data["new_password"]) + self.user.save() diff --git a/backend/back_app/users/urls.py b/backend/back_app/users/urls.py index bff4d67..7a606b4 100644 --- a/backend/back_app/users/urls.py +++ b/backend/back_app/users/urls.py @@ -3,8 +3,8 @@ PasswordResetConfirmView, PasswordResetRequestView, UserRegistrationView, - UserRetrieveUpdateView, UserRetrieveView, + UserViewSet, ) urlpatterns = [ @@ -16,5 +16,15 @@ name="reset-password-confirm", ), path("register/", UserRegistrationView.as_view(), name="user-register"), - path("profile//", UserRetrieveUpdateView.as_view(), name="user-retrieve"), + path( + "/", + UserViewSet.as_view( + { + "patch": "partial_update", + "put": "update", + "delete": "destroy", + } + ), + name="user-detail", + ), ] diff --git a/backend/back_app/users/views.py b/backend/back_app/users/views.py index 5df6df1..e68bd66 100644 --- a/backend/back_app/users/views.py +++ b/backend/back_app/users/views.py @@ -1,13 +1,4 @@ -import uuid - -from core.settings import URL_NGROK_HOST -from django.contrib.auth.tokens import default_token_generator -from django.core.mail import send_mail -from django.shortcuts import get_object_or_404 -from django.utils.encoding import force_bytes -from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode -from drf_yasg.utils import swagger_auto_schema -from rest_framework import generics, mixins, status +from rest_framework import generics, mixins, status, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from users.models import User @@ -20,6 +11,11 @@ ) +class UserRegistrationView(generics.CreateAPIView): + serializer_class = UserRegistrationSerializer + permission_classes = [AllowAny] + + class UserRetrieveView(generics.RetrieveAPIView): serializer_class = UserSerializer permission_classes = [IsAuthenticated, IsOwnerOrAdmin] @@ -28,118 +24,40 @@ def get_object(self): return self.request.user -class PasswordResetRequestView(mixins.CreateModelMixin, generics.GenericAPIView): +class PasswordResetRequestView(generics.GenericAPIView): serializer_class = PasswordResetRequestSerializer - permission_classes = [IsAuthenticated, IsOwnerOrAdmin] + permission_classes = [AllowAny] def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - - email = serializer.validated_data["email"] - user = User.objects.get(email=email) - - token = default_token_generator.make_token(user) - uid = urlsafe_base64_encode(force_bytes(user.pk)) - - reset_url = f"{URL_NGROK_HOST}/users/reset-password-confirm/{uid}/{token}/" - send_mail( - subject="Password Reset Request", - message=f"Use the link to reset your password: {reset_url}", - from_email=None, - recipient_list=[email], - ) - + serializer.send_reset_email() return Response( {"detail": "Password reset link sent."}, status=status.HTTP_200_OK ) -class PasswordResetConfirmView(mixins.UpdateModelMixin, generics.GenericAPIView): +class PasswordResetConfirmView(generics.GenericAPIView): serializer_class = PasswordResetConfirmSerializer - permission_classes = [IsAuthenticated, IsOwnerOrAdmin] + permission_classes = [AllowAny] def post(self, request, uidb64, token, *args, **kwargs): - - try: - uid = urlsafe_base64_decode(uidb64).decode() - user = User.objects.get(pk=uid) - except (TypeError, ValueError, User.DoesNotExist): - return Response( - {"detail": "Invalid user"}, status=status.HTTP_400_BAD_REQUEST - ) - - if not default_token_generator.check_token(user, token): - return Response( - {"detail": "Invalid or expired token"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - user.set_password(serializer.validated_data["new_password"]) - user.save() - - return Response( - {"detail": "Password has been reset."}, status=status.HTTP_200_OK + serializer = self.get_serializer( + data=request.data, context={"uidb64": uidb64, "token": token} ) - - -class UserRegistrationView(generics.CreateAPIView): - serializer_class = UserRegistrationSerializer - permission_classes = [AllowAny] - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() return Response( - {"detail": "User created successfully."}, status=status.HTTP_201_CREATED + {"detail": "Password has been reset."}, status=status.HTTP_200_OK ) -class UserRetrieveUpdateView( - generics.GenericAPIView, mixins.RetrieveModelMixin, mixins.UpdateModelMixin -): +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.all() serializer_class = UserSerializer - permission_classes = [IsAuthenticated, IsOwnerOrAdmin] - lookup_field = "id" - lookup_url_kwarg = "id" - - def get_object(self): - user_id = self.kwargs.get(self.lookup_url_kwarg) - - user = get_object_or_404(User, id=user_id) - self.check_object_permissions(self.request, user) - return user - - @swagger_auto_schema( - operation_description="Retrieve user profile by ID", - responses={200: UserSerializer()}, - ) - def get(self, request, *args, **kwargs): - return self.retrieve(request, *args, **kwargs) - - @swagger_auto_schema( - operation_description="Update user profile by ID", - request_body=UserSerializer, - responses={200: UserSerializer()}, - ) - def put(self, request, *args, **kwargs): - return self.update(request, *args, **kwargs) - - @swagger_auto_schema( - operation_description="Partially update user profile by ID", - request_body=UserSerializer, - responses={200: UserSerializer()}, - ) - def patch(self, request, *args, **kwargs): - return self.partial_update(request, *args, **kwargs) + permission_classes = [IsOwnerOrAdmin] - @swagger_auto_schema( - operation_description="Delete user profile by ID", - responses={204: "User deleted"}, - ) - def delete(self, request, *args, **kwargs): - return self.destroy(request, *args, **kwargs) + def get_queryset(self): + if self.request.user.is_staff: + return User.objects.all() + return User.objects.filter(id=self.request.user.id) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d9c8ad6..429163f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -2,7 +2,7 @@ name = "backend" version = "0.1.0" description = "" -authors = ["Snikls "] +authors = ["Snikls"] package-mode = false [tool.poetry.dependencies]