Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/back_app/library/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import datetime

from library.models import Book, Category
from library.services import scrape_book_info
from rest_framework import serializers


Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls Try moving validation to the validate_url or validate method. Also, instead of returning a generic 'Error scraping book,' try to provide a more specific and understandable error message (e.g., 'Book not found,' 'Invalid site URL,' etc.). If possible, the create method should operate only with already validated data, as long as it does not affect system optimization.

raise serializers.ValidationError(f"Error scraping book: {str(e)}")
self.created = created
return book
33 changes: 33 additions & 0 deletions backend/back_app/library/services.py
Original file line number Diff line number Diff line change
@@ -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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls Are you sure that you have only one category - "Scraped" ? As I understand you have to get category from response

book, created = Book.objects.update_or_create(
title=scraped_title,
category=default_category,
defaults={
"author": "Unknown",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls Check this hardcoded value. Mb u have to get it from response?

"publication_date": datetime.date.today(),
"description": scraped_description,
},
)
return book, created
46 changes: 2 additions & 44 deletions backend/back_app/library/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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},
Expand Down
18 changes: 18 additions & 0 deletions backend/back_app/users/migrations/0002_user_extra_fields.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
7 changes: 1 addition & 6 deletions backend/back_app/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -19,18 +18,14 @@ 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)


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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls Why do you need this field?


USERNAME_FIELD = "username"
EMAIL_FIELD = "email"
Expand Down
2 changes: 1 addition & 1 deletion backend/back_app/users/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
84 changes: 67 additions & 17 deletions backend/back_app/users/serializers.py
Original file line number Diff line number Diff line change
@@ -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"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls Add validate method to check data for user updating method


def __init__(self, *args, **kwargs):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls Instead of using __init__, you can use the standard serializer to_representation method, which should work the same way in this case.

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):
Expand All @@ -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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls Split these errors:

User.DoesNotExist should return a 404 error when something is not found.
For other errors related to user-entered data, use a 400 error as a generic client error status.

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()
14 changes: 12 additions & 2 deletions backend/back_app/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
PasswordResetConfirmView,
PasswordResetRequestView,
UserRegistrationView,
UserRetrieveUpdateView,
UserRetrieveView,
UserViewSet,
)

urlpatterns = [
Expand All @@ -16,5 +16,15 @@
name="reset-password-confirm",
),
path("register/", UserRegistrationView.as_view(), name="user-register"),
path("profile/<uuid:id>/", UserRetrieveUpdateView.as_view(), name="user-retrieve"),
path(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn1kls I think that you can use router here instead of use one path for 3 different methods

"<uuid:pk>/",
UserViewSet.as_view(
{
"patch": "partial_update",
"put": "update",
"delete": "destroy",
}
),
name="user-detail",
),
]
Loading