├── api
├── __init__.py
├── migrations
│ └── __init__.py
├── models.py
├── admin.py
├── apps.py
├── urls.py
├── views.py
├── serializers.py
└── tests.py
├── books
├── __init__.py
├── migrations
│ ├── __init__.py
│ ├── 0003_book_cover_picture.py
│ ├── 0004_bookreview_created_at.py
│ ├── 0002_initial.py
│ └── 0001_initial.py
├── apps.py
├── forms.py
├── templates
│ └── books
│ │ ├── confirm_delete_review.html
│ │ ├── edit_review.html
│ │ ├── list.html
│ │ └── detail.html
├── admin.py
├── urls.py
├── models.py
├── tests.py
└── views.py
├── users
├── __init__.py
├── migrations
│ ├── __init__.py
│ ├── 0002_customuser_profile_picture.py
│ └── 0001_initial.py
├── admin.py
├── models.py
├── apps.py
├── tasks.py
├── urls.py
├── templates
│ └── users
│ │ ├── profile_edit.html
│ │ ├── register.html
│ │ ├── login.html
│ │ └── profile.html
├── signals.py
├── forms.py
├── views.py
└── tests.py
├── .gitignore
├── goodreads
├── __init__.py
├── asgi.py
├── wsgi.py
├── views.py
├── urls.py
├── celery.py
├── tests.py
└── settings.py
├── requirements.txt
├── manage.py
├── templates
├── landing.html
├── home.html
└── base.html
└── static
└── css
└── main.css
/api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/books/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/users/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/api/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/books/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/users/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | env
3 | media-files
4 | .env
5 |
--------------------------------------------------------------------------------
/api/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | # Create your models here.
4 |
--------------------------------------------------------------------------------
/api/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/goodreads/__init__.py:
--------------------------------------------------------------------------------
1 | from .celery import app as celery_app
2 |
3 | __all__ = ('celery_app',)
4 |
--------------------------------------------------------------------------------
/users/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from users.models import CustomUser
4 |
5 |
6 | admin.site.register(CustomUser)
7 |
8 |
--------------------------------------------------------------------------------
/api/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class ApiConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'api'
7 |
--------------------------------------------------------------------------------
/books/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class BooksConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'books'
7 |
--------------------------------------------------------------------------------
/users/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AbstractUser
2 | from django.db import models
3 |
4 |
5 | class CustomUser(AbstractUser):
6 | profile_picture = models.ImageField(default="default_profile_pic.jpg")
7 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django==4.0
2 | celery==5.2.3
3 | crispy-bootstrap5==0.6
4 | django-crispy-forms==1.13.0
5 | djangorestframework==3.13.1
6 | django-environ==0.8.1
7 | gunicorn==20.1.0
8 | Pillow==8.4.0
9 | psycopg2-binary==2.9.2
10 |
--------------------------------------------------------------------------------
/users/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class UsersConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'users'
7 |
8 | def ready(self):
9 | import users.signals
10 |
--------------------------------------------------------------------------------
/api/urls.py:
--------------------------------------------------------------------------------
1 | from rest_framework.routers import DefaultRouter
2 | from api.views import BookReviewsViewSet
3 |
4 | app_name = "api"
5 |
6 | router = DefaultRouter()
7 | router.register('reviews', BookReviewsViewSet, basename='review')
8 | urlpatterns = router.urls
9 |
--------------------------------------------------------------------------------
/users/tasks.py:
--------------------------------------------------------------------------------
1 | from django.core.mail import send_mail
2 | from goodreads.celery import app
3 |
4 |
5 | @app.task()
6 | def send_email(subject, message, recipient_list):
7 | send_mail(
8 | subject,
9 | message,
10 | "jrahmonov2@gmail.com",
11 | recipient_list
12 | )
13 |
--------------------------------------------------------------------------------
/books/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 |
3 | from books.models import BookReview
4 |
5 |
6 | class BookReviewForm(forms.ModelForm):
7 | stars_given = forms.IntegerField(min_value=1, max_value=5)
8 |
9 | class Meta:
10 | model = BookReview
11 | fields = ('stars_given', 'comment')
12 |
--------------------------------------------------------------------------------
/api/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework.permissions import IsAuthenticated
2 | from rest_framework import viewsets
3 |
4 | from books.models import BookReview
5 | from api.serializers import BookReviewSerializer
6 |
7 |
8 | class BookReviewsViewSet(viewsets.ModelViewSet):
9 | permission_classes = [IsAuthenticated]
10 | serializer_class = BookReviewSerializer
11 | queryset = BookReview.objects.all().order_by('-created_at')
12 | lookup_field = 'id'
13 |
--------------------------------------------------------------------------------
/goodreads/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for goodreads project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'goodreads.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/goodreads/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for goodreads project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'goodreads.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/books/migrations/0003_book_cover_picture.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0 on 2022-01-02 22:36
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('books', '0002_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='book',
15 | name='cover_picture',
16 | field=models.ImageField(default='default_cover.jpg', upload_to=''),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/users/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from users.views import RegisterView, LoginView, ProfileView, LogoutView, ProfileUpdateView
4 |
5 |
6 | app_name = "users"
7 | urlpatterns = [
8 | path("register/", RegisterView.as_view(), name="register"),
9 | path("login/", LoginView.as_view(), name="login"),
10 | path("logout/", LogoutView.as_view(), name="logout"),
11 | path("profile/", ProfileView.as_view(), name="profile"),
12 | path("profile/edit/", ProfileUpdateView.as_view(), name="profile-edit"),
13 | ]
14 |
--------------------------------------------------------------------------------
/users/migrations/0002_customuser_profile_picture.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0 on 2022-01-02 20:43
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('users', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='customuser',
15 | name='profile_picture',
16 | field=models.ImageField(default='default_profile_pic.jpg', upload_to=''),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/users/templates/users/profile_edit.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load crispy_forms_tags %}
3 |
4 | {% block content %}
5 |
Edit Profile
6 |
7 |
19 |
20 |
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/books/migrations/0004_bookreview_created_at.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0 on 2022-01-06 21:19
2 |
3 | from django.db import migrations, models
4 | import django.utils.timezone
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('books', '0003_book_cover_picture'),
11 | ]
12 |
13 | operations = [
14 | migrations.AddField(
15 | model_name='bookreview',
16 | name='created_at',
17 | field=models.DateTimeField(default=django.utils.timezone.now),
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/books/templates/books/confirm_delete_review.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block title %} Confirm Delete Review {% endblock %}
4 |
5 | {% block content %}
6 | Are you sure you want to delete this review?
7 |
8 |
9 | Book : {{ book.title }}
10 |
11 |
12 | Comment : {{ review.comment }}
13 |
14 |
15 | Yes No
16 | {% endblock %}
17 |
--------------------------------------------------------------------------------
/users/templates/users/register.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load crispy_forms_tags %}
3 |
4 | {% block title %}Register Page{% endblock %}
5 |
6 | {% block content %}
7 | Register
8 |
9 |
20 |
21 | {% endblock %}
--------------------------------------------------------------------------------
/users/signals.py:
--------------------------------------------------------------------------------
1 | from django.core.mail import send_mail
2 | from django.db.models.signals import post_save
3 | from django.dispatch import receiver
4 |
5 | from users.models import CustomUser
6 | from users.tasks import send_email
7 |
8 |
9 | @receiver(post_save, sender=CustomUser)
10 | def send_welcome_email(sender, instance, created, **kwargs):
11 | if created:
12 | send_email.delay(
13 | "Welcome to Goodreads Clone",
14 | f"Hi, {instance.username}. Welcome to Goodreads Clone. Enjoy the books and reviews.",
15 | [instance.email]
16 | )
17 |
--------------------------------------------------------------------------------
/books/templates/books/edit_review.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load crispy_forms_tags %}
3 |
4 | {% block title %} Edit review {% endblock %}
5 |
6 | {% block content %}
7 | Edit review
8 |
9 |
21 | {% endblock %}
--------------------------------------------------------------------------------
/goodreads/views.py:
--------------------------------------------------------------------------------
1 | from django.core.paginator import Paginator
2 | from django.shortcuts import render
3 |
4 | from books.models import BookReview
5 |
6 |
7 | def landing_page(request):
8 | return render(request, "landing.html")
9 |
10 |
11 | def home_page(request):
12 | book_reviews = BookReview.objects.all().order_by('-created_at')
13 | page_size = request.GET.get('page_size', 10)
14 | paginator = Paginator(book_reviews, page_size)
15 |
16 | page_num = request.GET.get('page', 1)
17 | page_object = paginator.get_page(page_num)
18 |
19 | return render(request, "home.html", {"page_obj": page_object})
20 |
--------------------------------------------------------------------------------
/users/templates/users/login.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load crispy_forms_tags %}
3 |
4 | {% block title %}Login Page{% endblock %}
5 |
6 | {% block content %}
7 | Login
8 |
9 |
19 |
20 | {% endblock %}
21 |
--------------------------------------------------------------------------------
/users/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from users.models import CustomUser
3 |
4 |
5 | class UserCreateForm(forms.ModelForm):
6 | class Meta:
7 | model = CustomUser
8 | fields = ('username', 'first_name', 'last_name', 'email', 'password')
9 |
10 | def save(self, commit=True):
11 | user = super().save(commit)
12 | user.set_password(self.cleaned_data['password'])
13 | user.save()
14 |
15 | return user
16 |
17 |
18 | class UserUpdateForm(forms.ModelForm):
19 | class Meta:
20 | model = CustomUser
21 | fields = ('username', 'first_name', 'last_name', 'email', 'profile_picture')
22 |
--------------------------------------------------------------------------------
/books/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from books.models import Book, Author, BookAuthor, BookReview
3 |
4 |
5 | class BookAdmin(admin.ModelAdmin):
6 | search_fields = ('title', 'isbn')
7 | list_display = ('title', 'isbn', 'description')
8 |
9 |
10 | class AuthorAdmin(admin.ModelAdmin):
11 | pass
12 |
13 |
14 | class BookAuthorAdmin(admin.ModelAdmin):
15 | pass
16 |
17 |
18 | class BookReviewAdmin(admin.ModelAdmin):
19 | pass
20 |
21 |
22 | admin.site.register(Book, BookAdmin)
23 | admin.site.register(Author, AuthorAdmin)
24 | admin.site.register(BookAuthor, BookAuthorAdmin)
25 | admin.site.register(BookReview, BookReviewAdmin)
26 |
--------------------------------------------------------------------------------
/goodreads/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.conf.urls.static import static
3 | from django.contrib import admin
4 | from django.urls import path, include
5 |
6 | from .views import landing_page, home_page
7 |
8 |
9 | urlpatterns = [
10 | path("", landing_page, name="landing_page"),
11 | path("home/", home_page, name="home_page"),
12 | path("users/", include("users.urls")),
13 | path("books/", include("books.urls")),
14 | path("api/", include("api.urls")),
15 |
16 | path('admin/', admin.site.urls),
17 | path('api-auth/', include('rest_framework.urls'))
18 | ]
19 |
20 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
21 |
--------------------------------------------------------------------------------
/goodreads/celery.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from celery import Celery
4 |
5 | # Set the default Django settings module for the 'celery' program.
6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'goodreads.settings')
7 |
8 | app = Celery('goodreads')
9 |
10 | # Using a string here means the worker doesn't have to serialize
11 | # the configuration object to child processes.
12 | # - namespace='CELERY' means all celery-related configuration keys
13 | # should have a `CELERY_` prefix.
14 | app.config_from_object('django.conf:settings', namespace='CELERY')
15 |
16 | # Load task modules from all registered Django apps.
17 | app.autodiscover_tasks()
18 |
19 |
20 | @app.task(bind=True)
21 | def debug_task(self):
22 | print(f'Request: {self.request!r}')
23 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'goodreads.settings')
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == '__main__':
22 | main()
23 |
--------------------------------------------------------------------------------
/books/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from books.views import BooksView, BookDetailView, AddReviewView, EditReviewView, ConfirmDeleteReviewView, DeleteReviewView
4 |
5 | app_name = "books"
6 | urlpatterns = [
7 | path("", BooksView.as_view(), name="list"),
8 | path("/", BookDetailView.as_view(), name="detail"),
9 | path("/reviews/", AddReviewView.as_view(), name="reviews"),
10 | path("/reviews//edit/", EditReviewView.as_view(), name="edit-review"),
11 | path(
12 | "/reviews//delete/confirm/",
13 | ConfirmDeleteReviewView.as_view(),
14 | name="confirm-delete-review"
15 | ),
16 | path(
17 | "/reviews//delete/",
18 | DeleteReviewView.as_view(),
19 | name="delete-review"
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/users/templates/users/profile.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Profile Page{% endblock %}
4 |
5 | {% block content %}
6 |
7 |
8 |
9 |
10 |
11 |
{{ user.first_name }} {{ user.last_name }}
12 |
@{{ user.username }}
13 |
{{ user.email }}
14 |
15 |
Joined {{ user.date_joined | date:"M d, Y" }}
16 |
17 |
18 | {% endblock %}
--------------------------------------------------------------------------------
/api/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from books.models import Book, BookReview
4 | from users.models import CustomUser
5 |
6 |
7 | class BookSerializer(serializers.ModelSerializer):
8 | class Meta:
9 | model = Book
10 | fields = ('id', 'title', 'description', 'isbn')
11 |
12 |
13 | class UserSerializer(serializers.ModelSerializer):
14 | class Meta:
15 | model = CustomUser
16 | fields = ('id', 'first_name', 'last_name', 'email', 'username')
17 |
18 |
19 | class BookReviewSerializer(serializers.ModelSerializer):
20 | user = UserSerializer(read_only=True)
21 | book = BookSerializer(read_only=True)
22 | user_id = serializers.IntegerField(write_only=True)
23 | book_id = serializers.IntegerField(write_only=True)
24 |
25 | class Meta:
26 | model = BookReview
27 | fields = ('id', 'stars_given', 'comment', 'book', 'user', 'user_id', 'book_id')
28 |
--------------------------------------------------------------------------------
/books/migrations/0002_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0 on 2022-01-02 19:59
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | initial = True
10 |
11 | dependencies = [
12 | ('users', '0001_initial'),
13 | ('books', '0001_initial'),
14 | ]
15 |
16 | operations = [
17 | migrations.AddField(
18 | model_name='bookreview',
19 | name='user',
20 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.customuser'),
21 | ),
22 | migrations.AddField(
23 | model_name='bookauthor',
24 | name='author',
25 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.author'),
26 | ),
27 | migrations.AddField(
28 | model_name='bookauthor',
29 | name='book',
30 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.book'),
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/templates/landing.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 | Landing Page
5 |
6 |
7 | Goodreads is an American social cataloging website and a subsidiary of Amazon[1] that allows individuals to search its database of books, annotations, quotes, and reviews. Users can sign up and register books to generate library catalogs and reading lists. They can also create their own groups of book suggestions, surveys, polls, blogs, and discussions. The website's offices are located in San Francisco.[2]
8 | Goodreads was founded in December 2006 and launched in January 2007 by Otis Chandler and Elizabeth Khuri Chandler.[3][4][5] In December 2007, the site had 650,000 members[6] and 10,000,000 books had been added.[7] By July 2012, the site reported 10 million members, 20 million monthly visits, and thirty employees.[8] On March 28, 2013, Amazon announced its acquisition of Goodreads,[9] and by July 23, 2013, Goodreads announced their user base had grown to 20 million members.[10]
9 | By July 2019, the site had 90 million members.[11]
10 |
11 | {% endblock %}
12 |
--------------------------------------------------------------------------------
/goodreads/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.urls import reverse
3 |
4 | from books.models import Book, BookReview
5 | from users.models import CustomUser
6 |
7 |
8 | class HomePageTestCase(TestCase):
9 | def test_paginated_list(self):
10 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
11 | user = CustomUser.objects.create(
12 | username="jakhongir", first_name="Jakhongir", last_name="Rakhmonov", email="jrahmonov2@gmail.com"
13 | )
14 | user.set_password("somepass")
15 | user.save()
16 | review1 = BookReview.objects.create(book=book, user=user, stars_given=3, comment="Very good book")
17 | review2 = BookReview.objects.create(book=book, user=user, stars_given=4, comment="Useful book")
18 | review3 =BookReview.objects.create(book=book, user=user, stars_given=5, comment="Nice book")
19 |
20 | response = self.client.get(reverse("home_page") + "?page_size=2")
21 |
22 | self.assertContains(response, review3.comment)
23 | self.assertContains(response, review2.comment)
24 | self.assertNotContains(response, review1.comment)
25 |
--------------------------------------------------------------------------------
/static/css/main.css:
--------------------------------------------------------------------------------
1 | .profile-pic {
2 | width: 200px;
3 | height: 200px;
4 | border-radius: 50%;
5 | }
6 |
7 | .small-profile-pic {
8 | width: 100px;
9 | height: 100px;
10 | border-radius: 50%;
11 | }
12 |
13 | .cover-pic {
14 | width: 220px;
15 | height: 300px;
16 | }
17 |
18 | .posts-content{
19 | margin-top:20px;
20 | }
21 | .ui-w-40 {
22 | width: 40px !important;
23 | height: auto;
24 | }
25 | .default-style .ui-bordered {
26 | border: 1px solid rgba(24,28,33,0.06);
27 | }
28 | .ui-bg-cover {
29 | background-color: transparent;
30 | background-position: center center;
31 | background-size: cover;
32 | }
33 | .ui-rect {
34 | padding-top: 50% !important;
35 | }
36 | .ui-rect, .ui-rect-30, .ui-rect-60, .ui-rect-67, .ui-rect-75 {
37 | position: relative !important;
38 | display: block !important;
39 | padding-top: 100% !important;
40 | width: 100% !important;
41 | }
42 | .d-flex, .d-inline-flex, .media, .media>:not(.media-body), .jumbotron, .card {
43 | -ms-flex-negative: 1;
44 | flex-shrink: 1;
45 | }
46 | .bg-dark {
47 | background-color: rgba(24,28,33,0.9) !important;
48 | }
49 | .card-footer, .card hr {
50 | border-color: rgba(24,28,33,0.06);
51 | }
52 | .ui-rect-content {
53 | position: absolute !important;
54 | top: 0 !important;
55 | right: 0 !important;
56 | bottom: 0 !important;
57 | left: 0 !important;
58 | }
59 | .default-style .ui-bordered {
60 | border: 1px solid rgba(24,28,33,0.06);
61 | }
62 |
63 | .center {
64 | margin: 0 auto;
65 | display: block;
66 | }
67 |
--------------------------------------------------------------------------------
/books/models.py:
--------------------------------------------------------------------------------
1 | from django.core.validators import MinValueValidator, MaxValueValidator
2 | from django.db import models
3 | from django.utils import timezone
4 |
5 | from users.models import CustomUser
6 |
7 |
8 | class Book(models.Model):
9 | title = models.CharField(max_length=200)
10 | description = models.TextField()
11 | isbn = models.CharField(max_length=17)
12 | cover_picture = models.ImageField(default="default_cover.jpg")
13 |
14 | def __str__(self):
15 | return self.title
16 |
17 |
18 | class Author(models.Model):
19 | first_name = models.CharField(max_length=100)
20 | last_name = models.CharField(max_length=100)
21 | email = models.EmailField()
22 | bio = models.TextField()
23 |
24 | def __str__(self):
25 | return f"{self.first_name} {self.last_name}"
26 |
27 | def full_name(self):
28 | return f"{self.first_name} {self.last_name}"
29 |
30 |
31 | class BookAuthor(models.Model):
32 | book = models.ForeignKey(Book, on_delete=models.CASCADE)
33 | author = models.ForeignKey(Author, on_delete=models.CASCADE)
34 |
35 | def __str__(self):
36 | return f"{self.book.title} by {self.author.first_name} {self.author.last_name}"
37 |
38 |
39 | class BookReview(models.Model):
40 | user = models.ForeignKey(CustomUser, on_delete=models.CASCADE)
41 | book = models.ForeignKey(Book, on_delete=models.CASCADE)
42 | comment = models.TextField()
43 | stars_given = models.IntegerField(
44 | validators=[MinValueValidator(1), MaxValueValidator(5)]
45 | )
46 | created_at = models.DateTimeField(default=timezone.now)
47 |
48 | def __str__(self):
49 | return f"{self.stars_given} stars for {self.book.title} by {self.user.username}"
50 |
51 |
--------------------------------------------------------------------------------
/books/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0 on 2022-01-02 19:59
2 |
3 | import django.core.validators
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | initial = True
11 |
12 | dependencies = [
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name='Author',
18 | fields=[
19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20 | ('first_name', models.CharField(max_length=100)),
21 | ('last_name', models.CharField(max_length=100)),
22 | ('email', models.EmailField(max_length=254)),
23 | ('bio', models.TextField()),
24 | ],
25 | ),
26 | migrations.CreateModel(
27 | name='Book',
28 | fields=[
29 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
30 | ('title', models.CharField(max_length=200)),
31 | ('description', models.TextField()),
32 | ('isbn', models.CharField(max_length=17)),
33 | ],
34 | ),
35 | migrations.CreateModel(
36 | name='BookAuthor',
37 | fields=[
38 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
39 | ],
40 | ),
41 | migrations.CreateModel(
42 | name='BookReview',
43 | fields=[
44 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
45 | ('comment', models.TextField()),
46 | ('stars_given', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
47 | ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.book')),
48 | ],
49 | ),
50 | ]
51 |
--------------------------------------------------------------------------------
/books/templates/books/list.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Books Page{% endblock %}
4 |
5 | {% block content %}
6 | Books
7 |
8 |
17 |
18 | {% if page_obj %}
19 | {% for book in page_obj.object_list %}
20 |
21 |
22 |
23 |
24 |
25 |
32 |
33 | {% endfor %}
34 |
35 |
36 |
51 |
52 |
53 | {% else %}
54 | No books found.
55 | {% endif %}
56 | {% endblock %}
--------------------------------------------------------------------------------
/templates/home.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block title %} Home Page {% endblock %}
4 |
5 | {% block content %}
6 | All reviews
7 |
8 | {% for review in page_obj %}
9 |
10 |
11 |
12 |
13 |
14 |
22 |
23 |
24 | {{ review.comment | truncatechars:300 }}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {% endfor %}
34 |
35 |
36 |
53 |
54 | {% endblock %}
55 |
--------------------------------------------------------------------------------
/books/templates/books/detail.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load crispy_forms_tags %}
3 |
4 | {% block title %}Book Detail Page{% endblock %}
5 |
6 | {% block content %}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
{{ book.title }}
14 |
15 |
16 | Authored by
17 | {% for book_author in book.bookauthor_set.all %}
18 | {% if forloop.last %}
19 | {{ book_author.author.full_name }}
20 | {% else %}
21 | {{ book_author.author.full_name }},
22 | {% endif %}
23 | {% endfor %}
24 |
25 |
26 |
27 | {{ book.description }}
28 |
29 |
30 |
31 |
32 |
44 |
45 | {% if book.bookreview_set.exists %}
46 | Reviews
47 |
48 | {% for review in book.bookreview_set.all %}
49 |
50 |
51 |
52 |
53 |
54 |
{{ review.user.username }} rated it {{ review.stars_given }} stars
{{ review.created_at }}
55 | {% if review.user == request.user %}
56 |
57 |
58 | {% endif %}
59 |
{{ review.comment }}
60 |
61 |
62 |
63 |
64 | {% endfor %}
65 | {% endif %}
66 |
67 | {% endblock %}
--------------------------------------------------------------------------------
/users/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth import login, logout
3 | from django.contrib.auth.forms import AuthenticationForm
4 | from django.contrib.auth.mixins import LoginRequiredMixin
5 | from django.shortcuts import render, redirect
6 | from django.views import View
7 |
8 | from users.forms import UserCreateForm, UserUpdateForm
9 |
10 |
11 | class RegisterView(View):
12 | def get(self, request):
13 | create_form = UserCreateForm()
14 | context = {
15 | "form": create_form
16 | }
17 | return render(request, "users/register.html", context)
18 |
19 | def post(self, request):
20 | create_form = UserCreateForm(data=request.POST)
21 |
22 | if create_form.is_valid():
23 | create_form.save()
24 | return redirect('users:login')
25 | else:
26 | context = {
27 | "form": create_form
28 | }
29 | return render(request, "users/register.html", context)
30 |
31 |
32 | class LoginView(View):
33 | def get(self, request):
34 | login_form = AuthenticationForm()
35 |
36 | return render(request, "users/login.html", {"login_form": login_form})
37 |
38 | def post(self, request):
39 | login_form = AuthenticationForm(data=request.POST)
40 |
41 | if login_form.is_valid():
42 | user = login_form.get_user()
43 | login(request, user)
44 |
45 | messages.success(request, "You have successfully logged in.")
46 |
47 | return redirect("books:list")
48 | else:
49 | return render(request, "users/login.html", {"login_form": login_form})
50 |
51 |
52 | class ProfileView(LoginRequiredMixin, View):
53 | def get(self, request):
54 | return render(request, "users/profile.html", {"user": request.user})
55 |
56 |
57 | class LogoutView(LoginRequiredMixin, View):
58 | def get(self, request):
59 | logout(request)
60 | messages.info(request, "You have successfully logged out.")
61 | return redirect("landing_page")
62 |
63 |
64 | class ProfileUpdateView(LoginRequiredMixin, View):
65 | def get(self, request):
66 | user_update_form = UserUpdateForm(instance=request.user)
67 | return render(request, "users/profile_edit.html", {"form": user_update_form})
68 |
69 | def post(self, request):
70 | user_update_form = UserUpdateForm(
71 | instance=request.user,
72 | data=request.POST,
73 | files=request.FILES
74 | )
75 |
76 | if user_update_form.is_valid():
77 | user_update_form.save()
78 | messages.success(request, "You have successfully updated your profile.")
79 |
80 | return redirect("users:profile")
81 |
82 | return render(request, "users/profile_edit.html", {"form": user_update_form})
83 |
--------------------------------------------------------------------------------
/users/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0 on 2022-01-02 19:59
2 |
3 | import django.contrib.auth.models
4 | import django.contrib.auth.validators
5 | from django.db import migrations, models
6 | import django.utils.timezone
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | initial = True
12 |
13 | dependencies = [
14 | ('auth', '0012_alter_user_first_name_max_length'),
15 | ]
16 |
17 | operations = [
18 | migrations.CreateModel(
19 | name='CustomUser',
20 | fields=[
21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22 | ('password', models.CharField(max_length=128, verbose_name='password')),
23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
25 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
26 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
28 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
29 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
30 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
32 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
33 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
34 | ],
35 | options={
36 | 'verbose_name': 'user',
37 | 'verbose_name_plural': 'users',
38 | 'abstract': False,
39 | },
40 | managers=[
41 | ('objects', django.contrib.auth.models.UserManager()),
42 | ],
43 | ),
44 | ]
45 |
--------------------------------------------------------------------------------
/books/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.urls import reverse
3 |
4 | from books.models import Book
5 | from users.models import CustomUser
6 |
7 |
8 | class BooksTestCase(TestCase):
9 | """
10 | Test
11 | """
12 | def test_no_books(self):
13 | response = self.client.get(reverse("books:list"))
14 |
15 | self.assertContains(response, "No books found.")
16 |
17 | def test_books_list(self):
18 | book1 = Book.objects.create(title="Book1", description="Description1", isbn="123121")
19 | book2 = Book.objects.create(title="Book2", description="Description2", isbn="111111")
20 | book3 = Book.objects.create(title="Book3", description="Description3", isbn="333333")
21 |
22 | response = self.client.get(reverse("books:list") + "?page_size=2")
23 |
24 | for book in [book1, book2]:
25 | self.assertContains(response, book.title)
26 | self.assertNotContains(response, book3.title)
27 |
28 | response = self.client.get(reverse("books:list") + "?page=2&page_size=2")
29 |
30 | self.assertContains(response, book3.title)
31 |
32 | def test_detail_page(self):
33 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
34 |
35 | response = self.client.get(reverse("books:detail", kwargs={"id": book.id}))
36 |
37 | self.assertContains(response, book.title)
38 | self.assertContains(response, book.description)
39 |
40 | def test_search_books(self):
41 | book1 = Book.objects.create(title="Sport", description="Description1", isbn="123121")
42 | book2 = Book.objects.create(title="Guide", description="Description2", isbn="111111")
43 | book3 = Book.objects.create(title="Shoe Dog", description="Description3", isbn="333333")
44 |
45 | response = self.client.get(reverse("books:list") + "?q=sport")
46 | self.assertContains(response, book1.title)
47 | self.assertNotContains(response, book2.title)
48 | self.assertNotContains(response, book3.title)
49 |
50 | response = self.client.get(reverse("books:list") + "?q=guide")
51 | self.assertContains(response, book2.title)
52 | self.assertNotContains(response, book1.title)
53 | self.assertNotContains(response, book3.title)
54 |
55 | response = self.client.get(reverse("books:list") + "?q=shoe")
56 | self.assertContains(response, book3.title)
57 | self.assertNotContains(response, book1.title)
58 | self.assertNotContains(response, book2.title)
59 |
60 |
61 | class BookReviewTestCase(TestCase):
62 | def test_add_review(self):
63 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
64 | user = CustomUser.objects.create(
65 | username="jakhongir", first_name="Jakhongir", last_name="Rakhmonov", email="jrahmonov2@gmail.com"
66 | )
67 | user.set_password("somepass")
68 | user.save()
69 | self.client.login(username="jakhongir", password="somepass")
70 |
71 | self.client.post(reverse("books:reviews", kwargs={"id": book.id}), data={
72 | "stars_given": 3,
73 | "comment": "Nice book"
74 | })
75 | book_reviews = book.bookreview_set.all()
76 |
77 | self.assertEqual(book_reviews.count(), 1)
78 | self.assertEqual(book_reviews[0].stars_given, 3)
79 | self.assertEqual(book_reviews[0].comment, "Nice book")
80 | self.assertEqual(book_reviews[0].book, book)
81 | self.assertEqual(book_reviews[0].user, user)
82 |
--------------------------------------------------------------------------------
/books/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth.mixins import LoginRequiredMixin
3 | from django.core.paginator import Paginator
4 | from django.shortcuts import render, redirect
5 | from django.urls import reverse
6 | from django.views import View
7 |
8 | from books.forms import BookReviewForm
9 | from books.models import Book, BookReview
10 |
11 |
12 | class BooksView(View):
13 | def get(self, request):
14 | books = Book.objects.all().order_by('id')
15 | search_query = request.GET.get('q', '')
16 | if search_query:
17 | books = books.filter(title__icontains=search_query)
18 |
19 | page_size = request.GET.get('page_size', 4)
20 | paginator = Paginator(books, page_size)
21 |
22 | page_num = request.GET.get('page', 1)
23 | page_obj = paginator.get_page(page_num)
24 |
25 | return render(
26 | request,
27 | "books/list.html",
28 | {"page_obj": page_obj, "search_query": search_query}
29 | )
30 |
31 |
32 | class BookDetailView(View):
33 | def get(self, request, id):
34 | book = Book.objects.get(id=id)
35 | review_form = BookReviewForm()
36 |
37 | return render(request, "books/detail.html", {"book": book, "review_form": review_form})
38 |
39 |
40 | class AddReviewView(LoginRequiredMixin, View):
41 | def post(self, request, id):
42 | book = Book.objects.get(id=id)
43 | review_form = BookReviewForm(data=request.POST)
44 |
45 | if review_form.is_valid():
46 | BookReview.objects.create(
47 | book=book,
48 | user=request.user,
49 | stars_given=review_form.cleaned_data['stars_given'],
50 | comment=review_form.cleaned_data['comment']
51 | )
52 |
53 | return redirect(reverse("books:detail", kwargs={"id": book.id}))
54 |
55 | return render(request, "books/detail.html", {"book": book, "review_form": review_form})
56 |
57 |
58 | class EditReviewView(LoginRequiredMixin, View):
59 | def get(self, request, book_id, review_id):
60 | book = Book.objects.get(id=book_id)
61 | review = book.bookreview_set.get(id=review_id)
62 | review_form = BookReviewForm(instance=review)
63 |
64 | return render(request, "books/edit_review.html", {"book": book, "review": review, "review_form": review_form})
65 |
66 | def post(self, request, book_id, review_id):
67 | book = Book.objects.get(id=book_id)
68 | review = book.bookreview_set.get(id=review_id)
69 | review_form = BookReviewForm(instance=review, data=request.POST)
70 |
71 | if review_form.is_valid():
72 | review_form.save()
73 | return redirect(reverse("books:detail", kwargs={"id": book.id}))
74 |
75 | return render(request, "books/edit_review.html", {"book": book, "review": review, "review_form": review_form})
76 |
77 |
78 | class ConfirmDeleteReviewView(LoginRequiredMixin, View):
79 | def get(self, request, book_id, review_id):
80 | book = Book.objects.get(id=book_id)
81 | review = book.bookreview_set.get(id=review_id)
82 |
83 | return render(request, "books/confirm_delete_review.html", {"book": book, "review": review})
84 |
85 |
86 | class DeleteReviewView(LoginRequiredMixin, View):
87 | def get(self, request, book_id, review_id):
88 | book = Book.objects.get(id=book_id)
89 | review = book.bookreview_set.get(id=review_id)
90 |
91 | review.delete()
92 | messages.success(request, "You have successfully deleted this review")
93 |
94 | return redirect(reverse("books:detail", kwargs={"id": book.id}))
95 |
--------------------------------------------------------------------------------
/templates/base.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 | {% url 'home_page' as home_page_url %}
3 | {% url 'books:list' as books_page_url %}
4 | {% url 'users:profile' as profile_page_url %}
5 | {% url 'users:profile-edit' as profile_edit_page_url %}
6 |
7 |
8 |
9 |
10 |
11 | {% block title %}Goodreads Clone{% endblock %}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
Goodreads Clone
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Home
33 |
34 |
35 | Books
36 |
37 |
38 |
39 |
40 | {% if request.user.is_authenticated %}
41 |
53 | {% else %}
54 |
Login
55 | {% endif %}
56 |
57 |
58 |
59 |
60 |
61 |
62 | {% if messages %}
63 |
64 | {% for message in messages %}
65 |
66 | {{ message }}
67 |
68 | {% endfor %}
69 |
70 |
71 | {% endif %}
72 |
73 | {% block content %}{% endblock %}
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/goodreads/settings.py:
--------------------------------------------------------------------------------
1 | import environ
2 | import os
3 | from pathlib import Path
4 |
5 | env = environ.Env(
6 | # set casting, default value
7 | DEBUG=(bool, False)
8 | )
9 |
10 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
11 | BASE_DIR = Path(__file__).resolve().parent.parent
12 |
13 | environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
14 |
15 | # Quick-start development settings - unsuitable for production
16 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
17 |
18 | # SECURITY WARNING: keep the secret key used in production secret!
19 | SECRET_KEY = env('SECRET_KEY')
20 |
21 | # SECURITY WARNING: don't run with debug turned on in production!
22 | DEBUG = env('DEBUG')
23 |
24 | ALLOWED_HOSTS = ['*']
25 |
26 | LOGIN_URL = "users:login"
27 |
28 | # Application definition
29 |
30 | INSTALLED_APPS = [
31 | 'django.contrib.admin',
32 | 'django.contrib.auth',
33 | 'django.contrib.contenttypes',
34 | 'django.contrib.sessions',
35 | 'django.contrib.messages',
36 | 'django.contrib.staticfiles',
37 |
38 | "crispy_forms",
39 | "crispy_bootstrap5",
40 | "rest_framework",
41 |
42 | 'books',
43 | 'users',
44 | 'api'
45 | ]
46 |
47 | CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
48 | CRISPY_TEMPLATE_PACK = "bootstrap5"
49 |
50 | MIDDLEWARE = [
51 | 'django.middleware.security.SecurityMiddleware',
52 | 'django.contrib.sessions.middleware.SessionMiddleware',
53 | 'django.middleware.common.CommonMiddleware',
54 | 'django.middleware.csrf.CsrfViewMiddleware',
55 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
56 | 'django.contrib.messages.middleware.MessageMiddleware',
57 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
58 | ]
59 |
60 | ROOT_URLCONF = 'goodreads.urls'
61 |
62 | TEMPLATES = [
63 | {
64 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
65 | 'DIRS': [
66 | BASE_DIR / "templates"
67 | ],
68 | 'APP_DIRS': True,
69 | 'OPTIONS': {
70 | 'context_processors': [
71 | 'django.template.context_processors.debug',
72 | 'django.template.context_processors.request',
73 | 'django.contrib.auth.context_processors.auth',
74 | 'django.contrib.messages.context_processors.messages',
75 | ],
76 | },
77 | },
78 | ]
79 |
80 | WSGI_APPLICATION = 'goodreads.wsgi.application'
81 |
82 |
83 | # Database
84 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases
85 |
86 | DATABASES = {
87 | 'default': {
88 | 'ENGINE': 'django.db.backends.postgresql',
89 | 'NAME': env('DB_NAME'),
90 | 'HOST': env('DB_HOST'),
91 | 'PORT': '5432',
92 | 'USER': env('DB_USER'),
93 | 'PASSWORD': env('DB_PASSWORD')
94 | }
95 | }
96 |
97 |
98 | # Password validation
99 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
100 |
101 | AUTH_PASSWORD_VALIDATORS = [
102 | {
103 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
104 | },
105 | {
106 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
107 | },
108 | {
109 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
110 | },
111 | {
112 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
113 | },
114 | ]
115 |
116 |
117 | # Internationalization
118 | # https://docs.djangoproject.com/en/4.0/topics/i18n/
119 |
120 | LANGUAGE_CODE = 'en-us'
121 |
122 | TIME_ZONE = 'UTC'
123 |
124 | USE_I18N = True
125 |
126 | USE_TZ = True
127 |
128 | AUTH_USER_MODEL = "users.CustomUser"
129 |
130 | MEDIA_URL = "/media/"
131 | MEDIA_ROOT = "media-files"
132 |
133 | # Static files (CSS, JavaScript, Images)
134 | # https://docs.djangoproject.com/en/4.0/howto/static-files/
135 |
136 | STATIC_URL = 'static/'
137 | STATICFILES_DIRS = [
138 | BASE_DIR / 'static'
139 | ]
140 | STATIC_ROOT = 'static-files'
141 |
142 | # Default primary key field type
143 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
144 |
145 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
146 |
147 |
148 | EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
149 | EMAIL_HOST = 'smtp.gmail.com'
150 | EMAIL_HOST_USER = env('EMAIL_HOST_USER')
151 | EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD')
152 | EMAIL_PORT = 587
153 | EMAIL_USE_TLS = True
154 | EMAIL_USE_SSL = False
155 |
156 |
157 | REST_FRAMEWORK = {
158 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
159 | 'PAGE_SIZE': 2
160 | }
161 |
--------------------------------------------------------------------------------
/api/tests.py:
--------------------------------------------------------------------------------
1 | from rest_framework.reverse import reverse
2 | from rest_framework.test import APITestCase
3 |
4 | from books.models import Book, BookReview
5 | from users.models import CustomUser
6 |
7 |
8 | class BookReviewAPITestCase(APITestCase):
9 | def setUp(self):
10 | self.user = CustomUser.objects.create(username="jakhongir", first_name="Jakhongir")
11 | self.user.set_password("somepass")
12 | self.user.save()
13 | self.client.login(username="jakhongir", password="somepass")
14 |
15 | def test_book_review_detail(self):
16 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
17 | br = BookReview.objects.create(book=book, user=self.user, stars_given=5, comment="Very good book")
18 |
19 | response = self.client.get(reverse('api:review-detail', kwargs={'id': br.id}))
20 |
21 | self.assertEqual(response.status_code, 200)
22 | self.assertEqual(response.data['id'], br.id)
23 | self.assertEqual(response.data['stars_given'], 5)
24 | self.assertEqual(response.data['comment'], "Very good book")
25 | self.assertEqual(response.data['book']['id'], br.book.id)
26 | self.assertEqual(response.data['book']['title'], 'Book1')
27 | self.assertEqual(response.data['book']['description'], 'Description1')
28 | self.assertEqual(response.data['book']['isbn'], '123121')
29 | self.assertEqual(response.data['user']['id'], self.user.id)
30 | self.assertEqual(response.data['user']['first_name'], "Jakhongir")
31 | self.assertEqual(response.data['user']['username'], "jakhongir")
32 |
33 | def test_delete_review(self):
34 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
35 | br = BookReview.objects.create(book=book, user=self.user, stars_given=5, comment="Very good book")
36 |
37 | response = self.client.delete(reverse('api:review-detail', kwargs={'id': br.id}))
38 |
39 | self.assertEqual(response.status_code, 204)
40 | self.assertFalse(BookReview.objects.filter(id=br.id).exists())
41 |
42 | def test_patch_review(self):
43 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
44 | br = BookReview.objects.create(book=book, user=self.user, stars_given=5, comment="Very good book")
45 |
46 | response = self.client.patch(reverse('api:review-detail', kwargs={'id': br.id}), data={'stars_given': 4})
47 | br.refresh_from_db()
48 |
49 | self.assertEqual(response.status_code, 200)
50 | self.assertEqual(br.stars_given, 4)
51 |
52 | def test_put_review(self):
53 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
54 | br = BookReview.objects.create(book=book, user=self.user, stars_given=5, comment="Very good book")
55 |
56 | response = self.client.put(
57 | reverse('api:review-detail', kwargs={'id': br.id}),
58 | data={'stars_given': 4, 'comment': 'nice book', 'user_id': self.user.id, 'book_id': book.id}
59 | )
60 | br.refresh_from_db()
61 |
62 | self.assertEqual(response.status_code, 200)
63 | self.assertEqual(br.stars_given, 4)
64 | self.assertEqual(br.comment, 'nice book')
65 |
66 | def test_create_review(self):
67 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
68 | data = {
69 | 'stars_given': 2,
70 | 'comment': 'bad book',
71 | 'user_id': self.user.id,
72 | 'book_id': book.id
73 | }
74 |
75 | response = self.client.post(reverse('api:review-list'), data=data)
76 | br = BookReview.objects.get(book=book)
77 |
78 | self.assertEqual(response.status_code, 201)
79 | self.assertEqual(br.stars_given, 2)
80 | self.assertEqual(br.comment, "bad book")
81 |
82 | def test_book_review_list(self):
83 | user_two = CustomUser.objects.create(username="somebody", first_name="Somebody")
84 | book = Book.objects.create(title="Book1", description="Description1", isbn="123121")
85 | br = BookReview.objects.create(book=book, user=self.user, stars_given=5, comment="Very good book")
86 | br_two = BookReview.objects.create(book=book, user=user_two, stars_given=3, comment="Not good")
87 |
88 | response = self.client.get(reverse('api:review-list'))
89 |
90 | self.assertEqual(response.status_code, 200)
91 | self.assertEqual(len(response.data['results']), 2)
92 | self.assertEqual(response.data['count'], 2)
93 | self.assertIn('next', response.data)
94 | self.assertIn('previous', response.data)
95 | self.assertEqual(response.data['results'][0]['id'], br_two.id)
96 | self.assertEqual(response.data['results'][0]['stars_given'], br_two.stars_given)
97 | self.assertEqual(response.data['results'][0]['comment'], br_two.comment)
98 | self.assertEqual(response.data['results'][1]['id'], br.id)
99 | self.assertEqual(response.data['results'][1]['stars_given'], br.stars_given)
100 | self.assertEqual(response.data['results'][1]['comment'], br.comment)
101 |
--------------------------------------------------------------------------------
/users/tests.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user
2 | from django.test import TestCase
3 | from django.urls import reverse
4 |
5 | from users.models import CustomUser
6 |
7 |
8 | class RegistrationTestCase(TestCase):
9 | def test_user_account_is_created(self):
10 | self.client.post(
11 | reverse("users:register"),
12 | data={
13 | "username": "jakhongir",
14 | "first_name": "Jakhongir",
15 | "last_name": "Rakhmonov",
16 | "email": "jrahmonov2@gmail.com",
17 | "password": "somepassword"
18 | }
19 | )
20 |
21 | user = CustomUser.objects.get(username="jakhongir")
22 |
23 | self.assertEqual(user.first_name, "Jakhongir")
24 | self.assertEqual(user.last_name, "Rakhmonov")
25 | self.assertEqual(user.email, "jrahmonov2@gmail.com")
26 | self.assertNotEqual(user.password, "somepassword")
27 | self.assertTrue(user.check_password("somepassword"))
28 |
29 | def test_required_fields(self):
30 | response = self.client.post(
31 | reverse("users:register"),
32 | data={
33 | "first_name": "Jakhongir",
34 | "email": "jrahmonov2@gmail.com"
35 | }
36 | )
37 |
38 | user_count = CustomUser.objects.count()
39 |
40 | self.assertEqual(user_count, 0)
41 | self.assertFormError(response, "form", "username", "This field is required.")
42 | self.assertFormError(response, "form", "password", "This field is required.")
43 |
44 | def test_invalid_email(self):
45 | response = self.client.post(
46 | reverse("users:register"),
47 | data={
48 | "username": "jakhongir",
49 | "first_name": "Jakhongir",
50 | "last_name": "Rakhmonov",
51 | "email": "invalid-email",
52 | "password": "somepassword"
53 | }
54 | )
55 |
56 | user_count = CustomUser.objects.count()
57 |
58 | self.assertEqual(user_count, 0)
59 | self.assertFormError(response, "form", "email", "Enter a valid email address.")
60 |
61 | def test_unique_username(self):
62 | user = CustomUser.objects.create(username="jakhongir", first_name="Jakhongir")
63 | user.set_password("somepass")
64 | user.save()
65 |
66 | response = self.client.post(
67 | reverse("users:register"),
68 | data={
69 | "username": "jakhongir",
70 | "first_name": "Jakhongir",
71 | "last_name": "Rakhmonov",
72 | "email": "jrahmonov2@gmail.com",
73 | "password": "somepassword"
74 | }
75 | )
76 |
77 | user_count = CustomUser.objects.count()
78 | self.assertEqual(user_count, 1)
79 | self.assertFormError(response, "form", "username", "A user with that username already exists.")
80 |
81 |
82 | class LoginTestCase(TestCase):
83 | def setUp(self):
84 | # DRY - Dont repeat yourself
85 | self.db_user = CustomUser.objects.create(username="jakhongir", first_name="Jakhongir")
86 | self.db_user.set_password("somepass")
87 | self.db_user.save()
88 |
89 | def test_successful_login(self):
90 | self.client.post(
91 | reverse("users:login"),
92 | data={
93 | "username": "jakhongir",
94 | "password": "somepass"
95 | }
96 | )
97 |
98 | user = get_user(self.client)
99 | self.assertTrue(user.is_authenticated)
100 |
101 | def test_wrong_credentials(self):
102 | self.client.post(
103 | reverse("users:login"),
104 | data={
105 | "username": "wrong-username",
106 | "password": "somepass"
107 | }
108 | )
109 |
110 | user = get_user(self.client)
111 | self.assertFalse(user.is_authenticated)
112 |
113 | self.client.post(
114 | reverse("users:login"),
115 | data={
116 | "username": "jakhongir",
117 | "password": "wrong-password"
118 | }
119 | )
120 |
121 | user = get_user(self.client)
122 | self.assertFalse(user.is_authenticated)
123 |
124 | def test_logout(self):
125 | self.client.login(username="jakhongir", password="somepass")
126 |
127 | self.client.get(reverse("users:logout"))
128 |
129 | user = get_user(self.client)
130 | self.assertFalse(user.is_authenticated)
131 |
132 |
133 | class ProfileTestCase(TestCase):
134 | def test_login_required(self):
135 | response = self.client.get(reverse("users:profile"))
136 |
137 | self.assertEqual(response.status_code, 302)
138 | self.assertEqual(response.url, reverse("users:login") + "?next=/users/profile/")
139 |
140 | def test_profile_details(self):
141 | user = CustomUser.objects.create(
142 | username="jakhongir", first_name="Jakhongir", last_name="Rakhmonov", email="jrahmonov2@gmail.com"
143 | )
144 | user.set_password("somepass")
145 | user.save()
146 |
147 | self.client.login(username="jakhongir", password="somepass")
148 |
149 | response = self.client.get(reverse("users:profile"))
150 |
151 | self.assertEqual(response.status_code, 200)
152 | self.assertContains(response, user.username)
153 | self.assertContains(response, user.first_name)
154 | self.assertContains(response, user.last_name)
155 | self.assertContains(response, user.email)
156 |
157 | def test_update_profile(self):
158 | user = CustomUser.objects.create(
159 | username="jakhongir", first_name="Jakhongir", last_name="Rakhmonov", email="jrahmonov2@gmail.com"
160 | )
161 | user.set_password("somepass")
162 | user.save()
163 | self.client.login(username="jakhongir", password="somepass")
164 |
165 | response = self.client.post(
166 | reverse("users:profile-edit"),
167 | data={
168 | "username": "jakhongir",
169 | "first_name": "Jakhongir",
170 | "last_name": "Doe",
171 | "email": "jrahmonov3@gmail.com"
172 | }
173 | )
174 | user.refresh_from_db()
175 |
176 | self.assertEqual(user.last_name, "Doe")
177 | self.assertEqual(user.email, "jrahmonov3@gmail.com")
178 | self.assertEqual(response.url, reverse("users:profile"))
179 |
--------------------------------------------------------------------------------