From ac3da435ddb0e14fadc7a9bb42450a9c0e5d5099 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Fri, 20 Jan 2023 18:09:44 +0500 Subject: [PATCH 01/23] feat: add models --- core/__init__.py | 0 core/admin.py | 3 ++ core/apps.py | 6 ++++ core/migrations/__init__.py | 0 core/models.py | 62 +++++++++++++++++++++++++++++++++++++ core/tests.py | 3 ++ core/views.py | 3 ++ lms/settings.py | 3 ++ 8 files changed, 80 insertions(+) create mode 100644 core/__init__.py create mode 100644 core/admin.py create mode 100644 core/apps.py create mode 100644 core/migrations/__init__.py create mode 100644 core/models.py create mode 100644 core/tests.py create mode 100644 core/views.py diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..fad9143 --- /dev/null +++ b/core/models.py @@ -0,0 +1,62 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.utils.translation import gettext_lazy as _ + + +class User(AbstractUser): + phone_number = models.CharField(max_length=20, blank=True) + gender = models.CharField(max_length=10, blank=True) + books = models.ManyToManyField('Book', through='BookLoan', related_name='users') + + +class Book(models.Model): + name = models.CharField(max_length=100) + cover = models.ImageField(upload_to='books/') + author = models.CharField(max_length=100) + publisher = models.CharField(max_length=100) + stock = models.IntegerField() + + def __str__(self): + return self.name + + +class Librarian(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + + def __str__(self): + return self.user.username + + +class BookLoan(models.Model): + class BookLoanStatus(models.TextChoices): + REQUESTED = 'requested', _('Requested') + ISSUED = 'issued', _('Issued') + REJECTED = 'rejected', _('Rejected') + RETURNED = 'returned', _('Returned') + + user = models.ForeignKey(User, on_delete=models.CASCADE) + book = models.ForeignKey(Book, on_delete=models.CASCADE) + status = models.CharField(max_length=10, choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED) + created_at = models.DateTimeField(auto_now_add=True) + date_borrowed = models.DateField(null=True, blank=True) + date_due = models.DateField(null=True, blank=True) + date_returned = models.DateField(null=True, blank=True) + + def __str__(self): + return f'{self.user.username} - {self.book.name}' + + +class BookRequest(models.Model): + class BookRequestStatus(models.TextChoices): + PENDING = 'pending', _('Pending') + APPROVED = 'approved', _('Approved') + REJECTED = 'rejected', _('Rejected') + + user = models.ForeignKey(User, on_delete=models.CASCADE) + book_name = models.CharField(max_length=100) + status = models.CharField(max_length=20, choices=BookRequestStatus.choices, default=BookRequestStatus.PENDING) + created_at = models.DateTimeField(auto_now_add=True) + reason = models.CharField(max_length=200, blank=True) + + def __str__(self): + return f'{self.user.username} - {self.book_name}' diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/core/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/lms/settings.py b/lms/settings.py index 804202b..f73e67a 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -42,6 +42,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + "core.apps.CoreConfig", ] MIDDLEWARE = [ @@ -130,3 +131,5 @@ # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +AUTH_USER_MODEL = 'core.User' From dabc6b2cf86d86a67a65e896e59e455891da4e85 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 23 Jan 2023 15:09:01 +0500 Subject: [PATCH 02/23] Feat: add books api --- books/signature.png | Bin 0 -> 30496 bytes core/migrations/0001_initial.py | 247 ++++++++++++++++++++++++++++++++ core/permissions.py | 15 ++ core/serializers.py | 8 ++ core/urls.py | 8 ++ core/views.py | 15 +- lms/settings.py | 3 + lms/urls.py | 3 +- requirements.txt | 2 + 9 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 books/signature.png create mode 100644 core/migrations/0001_initial.py create mode 100644 core/permissions.py create mode 100644 core/serializers.py create mode 100644 core/urls.py diff --git a/books/signature.png b/books/signature.png new file mode 100644 index 0000000000000000000000000000000000000000..b729f1728fe03c92e222e0f9e4b853c2a81f0b59 GIT binary patch literal 30496 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

S3vB0;-Np7uU(Ho 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 literal 0 HcmV?d00001 diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..5b20cbd --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,247 @@ +# Generated by Django 4.1.5 on 2023-01-23 08:08 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField(blank=True, null=True, verbose_name="last login"), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "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", + ), + ), + ( + "first_name", + models.CharField(blank=True, max_length=150, verbose_name="first name"), + ), + ( + "last_name", + models.CharField(blank=True, max_length=150, verbose_name="last name"), + ), + ( + "email", + models.EmailField(blank=True, max_length=254, verbose_name="email address"), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "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", + ), + ), + ( + "date_joined", + models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"), + ), + ("phone_number", models.CharField(blank=True, max_length=20)), + ("gender", models.CharField(blank=True, max_length=10)), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="Book", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("cover", models.ImageField(upload_to="books/")), + ("author", models.CharField(max_length=100)), + ("publisher", models.CharField(max_length=100)), + ("stock", models.IntegerField()), + ], + ), + migrations.CreateModel( + name="Librarian", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="BookRequest", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("book_name", models.CharField(max_length=100)), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], + default="pending", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("reason", models.CharField(blank=True, max_length=200)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="BookLoan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("requested", "Requested"), + ("issued", "Issued"), + ("rejected", "Rejected"), + ("returned", "Returned"), + ], + default="requested", + max_length=10, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("date_borrowed", models.DateField(blank=True, null=True)), + ("date_due", models.DateField(blank=True, null=True)), + ("date_returned", models.DateField(blank=True, null=True)), + ( + "book", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.book"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddField( + model_name="user", + name="books", + field=models.ManyToManyField(related_name="users", through="core.BookLoan", to="core.book"), + ), + migrations.AddField( + model_name="user", + name="groups", + field=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", + ), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=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", + ), + ), + ] diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..9f0698b --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,15 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from .models import Librarian + + +class IsLibrarianOrReadOnly(BasePermission): + def has_permission(self, request, view): + is_librarian = False + if request.user.is_authenticated: + try: + Librarian.objects.get(user=request.user) + is_librarian = True + except Librarian.DoesNotExist: + pass + + return bool(request.method in SAFE_METHODS or is_librarian) diff --git a/core/serializers.py b/core/serializers.py new file mode 100644 index 0000000..7626296 --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from .models import Book + + +class BookSerializer(serializers.ModelSerializer): + class Meta: + model = Book + fields = ['name', 'cover', 'author', 'publisher', 'stock'] diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..fa5eb8b --- /dev/null +++ b/core/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from core.views import BookViewSet + +router = DefaultRouter() +router.register(r"books", BookViewSet) + +urlpatterns = [path("", include(router.urls))] diff --git a/core/views.py b/core/views.py index 91ea44a..9e3662a 100644 --- a/core/views.py +++ b/core/views.py @@ -1,3 +1,14 @@ -from django.shortcuts import render +from rest_framework.filters import SearchFilter +from rest_framework import viewsets -# Create your views here. +from .permissions import IsLibrarianOrReadOnly +from .models import Book +from .serializers import BookSerializer + + +class BookViewSet(viewsets.ModelViewSet): + queryset = Book.objects.all() + serializer_class = BookSerializer + filter_backends = [SearchFilter] + search_fields = ['name', 'author', 'publisher'] + permission_classes = [IsLibrarianOrReadOnly] diff --git a/lms/settings.py b/lms/settings.py index f73e67a..2ca9001 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -42,6 +42,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'django_filters', "core.apps.CoreConfig", ] @@ -133,3 +134,5 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' AUTH_USER_MODEL = 'core.User' + +REST_FRAMEWORK = {'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend']} diff --git a/lms/urls.py b/lms/urls.py index f03d04e..49b0469 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -14,8 +14,9 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + path("", include("core.urls")), ] diff --git a/requirements.txt b/requirements.txt index 1b08194..c4f0275 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ Django==4.1.5 djangorestframework==3.14.0 django-environ==0.9.0 +psycopg2==2.9.5 +pillow==9.4 \ No newline at end of file From 008a898e924b5f05293921f4858c5edbaaa4a44a Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 23 Jan 2023 15:51:51 +0500 Subject: [PATCH 03/23] Feat: add auth backend --- lms/settings.py | 10 +++++++++- lms/urls.py | 2 ++ requirements.txt | 4 +++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lms/settings.py b/lms/settings.py index 2ca9001..3e7e90d 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -43,6 +43,7 @@ 'django.contrib.staticfiles', 'rest_framework', 'django_filters', + 'djoser', "core.apps.CoreConfig", ] @@ -135,4 +136,11 @@ AUTH_USER_MODEL = 'core.User' -REST_FRAMEWORK = {'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend']} +REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework_simplejwt.authentication.JWTAuthentication'], +} + +SIMPLE_JWT = { + 'AUTH_HEADER_TYPES': ('JWT',), +} diff --git a/lms/urls.py b/lms/urls.py index 49b0469..e3f599a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -18,5 +18,7 @@ urlpatterns = [ path('admin/', admin.site.urls), + path('auth/', include('djoser.urls')), + path('auth/', include('djoser.urls.jwt')), path("", include("core.urls")), ] diff --git a/requirements.txt b/requirements.txt index c4f0275..0491e62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ Django==4.1.5 djangorestframework==3.14.0 +djangorestframework-simplejwt==5.2.2 +djoser==2.1.0 django-environ==0.9.0 psycopg2==2.9.5 -pillow==9.4 \ No newline at end of file +pillow==9.4 From 1063a3007b8a944b3deb9988a9509bd6444622da Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 23 Jan 2023 15:54:24 +0500 Subject: [PATCH 04/23] feat!: change BookViewSet permission method --- core/permissions.py | 6 +++--- core/views.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/core/permissions.py b/core/permissions.py index 9f0698b..71796f0 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -1,8 +1,8 @@ -from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.permissions import BasePermission from .models import Librarian -class IsLibrarianOrReadOnly(BasePermission): +class IsLibrarian(BasePermission): def has_permission(self, request, view): is_librarian = False if request.user.is_authenticated: @@ -12,4 +12,4 @@ def has_permission(self, request, view): except Librarian.DoesNotExist: pass - return bool(request.method in SAFE_METHODS or is_librarian) + return is_librarian diff --git a/core/views.py b/core/views.py index 9e3662a..6b265b3 100644 --- a/core/views.py +++ b/core/views.py @@ -1,7 +1,8 @@ from rest_framework.filters import SearchFilter from rest_framework import viewsets +from rest_framework.permissions import SAFE_METHODS, AllowAny, IsAuthenticated -from .permissions import IsLibrarianOrReadOnly +from .permissions import IsLibrarian from .models import Book from .serializers import BookSerializer @@ -11,4 +12,10 @@ class BookViewSet(viewsets.ModelViewSet): serializer_class = BookSerializer filter_backends = [SearchFilter] search_fields = ['name', 'author', 'publisher'] - permission_classes = [IsLibrarianOrReadOnly] + + def get_permissions(self): + if self.request.method not in SAFE_METHODS: + permission_classes = [IsAuthenticated, IsLibrarian] + else: + permission_classes = [AllowAny] + return [permission() for permission in permission_classes] From 044e041bc0b3cb86cd19c2b0bc371c9162ca3069 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 23 Jan 2023 18:38:23 +0500 Subject: [PATCH 05/23] feat: add book loan view --- core/admin.py | 13 ++++++++++++- core/models.py | 8 ++++++++ core/permissions.py | 22 ++++++++++++---------- core/serializers.py | 18 ++++++++++++++++-- core/urls.py | 7 +++++-- core/views.py | 35 +++++++++++++++++++++++++---------- lms/settings.py | 3 ++- 7 files changed, 80 insertions(+), 26 deletions(-) diff --git a/core/admin.py b/core/admin.py index 8c38f3f..f99c0ca 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,14 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -# Register your models here. +from core.models import User, Librarian + + +@admin.register(Librarian) +class UserAdmin(admin.ModelAdmin): + pass + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + pass diff --git a/core/models.py b/core/models.py index fad9143..2aac27b 100644 --- a/core/models.py +++ b/core/models.py @@ -8,6 +8,14 @@ class User(AbstractUser): gender = models.CharField(max_length=10, blank=True) books = models.ManyToManyField('Book', through='BookLoan', related_name='users') + @property + def is_librarian(self): + try: + Librarian.objects.get(user_id=self.id) + return True + except Librarian.DoesNotExist: + return False + class Book(models.Model): name = models.CharField(max_length=100) diff --git a/core/permissions.py b/core/permissions.py index 71796f0..ca57217 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -1,15 +1,17 @@ -from rest_framework.permissions import BasePermission +from rest_framework.permissions import BasePermission, SAFE_METHODS from .models import Librarian class IsLibrarian(BasePermission): def has_permission(self, request, view): - is_librarian = False - if request.user.is_authenticated: - try: - Librarian.objects.get(user=request.user) - is_librarian = True - except Librarian.DoesNotExist: - pass - - return is_librarian + return request.user.is_librarian + + +class ReadOnly(BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS + + +class IsLoanOwner(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.user == request.user diff --git a/core/serializers.py b/core/serializers.py index 7626296..e21e84f 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,8 +1,22 @@ from rest_framework import serializers -from .models import Book +from .models import Book, BookLoan class BookSerializer(serializers.ModelSerializer): class Meta: model = Book - fields = ['name', 'cover', 'author', 'publisher', 'stock'] + fields = ['id', 'name', 'cover', 'author', 'publisher', 'stock'] + + +class FullBookLoanSerializer(serializers.ModelSerializer): + class Meta: + model = BookLoan + fields = ['id', 'user', 'book', 'status', 'created_at', 'date_borrowed', 'date_due', 'date_returned'] + read_only_fields = ('user', 'created_at') + + +class BasicBookLoanSerializer(serializers.ModelSerializer): + class Meta: + model = BookLoan + fields = ['id', 'user', 'book', 'status', 'created_at', 'date_borrowed', 'date_due', 'date_returned'] + read_only_fields = ('user', 'status', 'created_at', 'date_borrowed', 'date_due', 'date_returned') diff --git a/core/urls.py b/core/urls.py index fa5eb8b..d72bd99 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,8 +1,11 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from core.views import BookViewSet +from core.views import BookLoanViewSet, BookViewSet router = DefaultRouter() router.register(r"books", BookViewSet) +router.register(r"loans", BookLoanViewSet) -urlpatterns = [path("", include(router.urls))] +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/views.py b/core/views.py index 6b265b3..3e1f25f 100644 --- a/core/views.py +++ b/core/views.py @@ -1,10 +1,10 @@ from rest_framework.filters import SearchFilter from rest_framework import viewsets -from rest_framework.permissions import SAFE_METHODS, AllowAny, IsAuthenticated +from rest_framework.permissions import SAFE_METHODS, AllowAny, IsAuthenticated, IsAdminUser -from .permissions import IsLibrarian -from .models import Book -from .serializers import BookSerializer +from .permissions import IsLibrarian, IsLoanOwner, ReadOnly +from .models import Book, BookLoan +from .serializers import FullBookLoanSerializer, BasicBookLoanSerializer, BookSerializer class BookViewSet(viewsets.ModelViewSet): @@ -12,10 +12,25 @@ class BookViewSet(viewsets.ModelViewSet): serializer_class = BookSerializer filter_backends = [SearchFilter] search_fields = ['name', 'author', 'publisher'] + permission_classes = [IsAdminUser | IsLibrarian | ReadOnly] - def get_permissions(self): - if self.request.method not in SAFE_METHODS: - permission_classes = [IsAuthenticated, IsLibrarian] - else: - permission_classes = [AllowAny] - return [permission() for permission in permission_classes] + +class BookLoanViewSet(viewsets.ModelViewSet): + queryset = BookLoan.objects.all() + permission_classes = [IsAdminUser | IsLibrarian | IsLoanOwner | ReadOnly] + + def get_queryset(self): + user = self.request.user + + if user.is_staff or user.is_librarian: + return BookLoan.objects.all() + + return BookLoan.objects.filter(user_id=user.id) + + def get_serializer_class(self): + if self.request.user.is_librarian: + return FullBookLoanSerializer + return BasicBookLoanSerializer + + def perform_create(self, serializer): + return serializer.save(user=self.request.user) diff --git a/lms/settings.py b/lms/settings.py index 3e7e90d..cc27b33 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ """ - +from datetime import timedelta from pathlib import Path import environ @@ -143,4 +143,5 @@ SIMPLE_JWT = { 'AUTH_HEADER_TYPES': ('JWT',), + "ACCESS_TOKEN_LIFETIME": timedelta(days=1), } From 8d6fc0ac2890d33f01b0a443e4255fa0b3e5cccd Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Tue, 24 Jan 2023 12:23:06 +0500 Subject: [PATCH 06/23] feat: update book stock When a loan is issued or returned update book stock accordingly --- core/apps.py | 3 +++ core/signals.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 core/signals.py diff --git a/core/apps.py b/core/apps.py index 8115ae6..df7e09f 100644 --- a/core/apps.py +++ b/core/apps.py @@ -4,3 +4,6 @@ class CoreConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core' + + def ready(self) -> None: + import core.signals diff --git a/core/signals.py b/core/signals.py new file mode 100644 index 0000000..eacc9e5 --- /dev/null +++ b/core/signals.py @@ -0,0 +1,19 @@ +from django.db.models.signals import pre_save +from django.dispatch import receiver +from django.db.models import F + +from .models import Book, BookLoan + + +@receiver(pre_save, sender=BookLoan) +def update_inventory(sender, **kwargs): + loan_instance: BookLoan = kwargs["instance"] + if loan_instance.id is None: # new object will be created + pass + else: + loan_previous = BookLoan.objects.get(id=loan_instance.id) + if loan_previous.status != loan_instance.status: # status updated + if loan_instance.status == 'issued': + Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") - 1) + elif loan_instance.status == 'returned': + Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") + 1) From bc6f012715304df4bfa972f23a96994d322c04a5 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Tue, 24 Jan 2023 15:09:39 +0500 Subject: [PATCH 07/23] feat: validate book loan request --- .gitignore | 2 + books/signature.png | Bin 30496 -> 0 bytes core/migrations/0002_alter_book_cover.py | 18 ++++++++ core/models.py | 2 +- core/serializers.py | 51 +++++++++++++++++++---- core/signals.py | 4 +- core/views.py | 9 ++-- requirements.txt | 1 + 8 files changed, 73 insertions(+), 14 deletions(-) delete mode 100644 books/signature.png create mode 100644 core/migrations/0002_alter_book_cover.py diff --git a/.gitignore b/.gitignore index 7373ddc..77dc6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,5 @@ GitHub.sublime-settings !.vscode/launch.json !.vscode/extensions.json .history + +uploads/ diff --git a/books/signature.png b/books/signature.png deleted file mode 100644 index b729f1728fe03c92e222e0f9e4b853c2a81f0b59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30496 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

S3vB0;-Np7uU(Ho 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 diff --git a/core/migrations/0002_alter_book_cover.py b/core/migrations/0002_alter_book_cover.py new file mode 100644 index 0000000..de2fdf5 --- /dev/null +++ b/core/migrations/0002_alter_book_cover.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.5 on 2023-01-24 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="book", + name="cover", + field=models.ImageField(upload_to="files/"), + ), + ] diff --git a/core/models.py b/core/models.py index 2aac27b..356284a 100644 --- a/core/models.py +++ b/core/models.py @@ -19,7 +19,7 @@ def is_librarian(self): class Book(models.Model): name = models.CharField(max_length=100) - cover = models.ImageField(upload_to='books/') + cover = models.ImageField(upload_to='files/') author = models.CharField(max_length=100) publisher = models.CharField(max_length=100) stock = models.IntegerField() diff --git a/core/serializers.py b/core/serializers.py index e21e84f..f41fa8b 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,3 +1,4 @@ +from django.db.models import Q from rest_framework import serializers from .models import Book, BookLoan @@ -5,18 +6,54 @@ class BookSerializer(serializers.ModelSerializer): class Meta: model = Book - fields = ['id', 'name', 'cover', 'author', 'publisher', 'stock'] + fields = ["id", "name", "cover", "author", "publisher", "stock"] -class FullBookLoanSerializer(serializers.ModelSerializer): +class BaseBookLoanSerializer(serializers.ModelSerializer): + book = serializers.PrimaryKeyRelatedField(queryset=Book.objects.filter(~Q(stock=0))) + + def validate(self, attrs): + loan = BookLoan(**attrs) + book = Book.objects.get(pk=loan.book.id) + if book.stock == 0: + raise serializers.ValidationError("Requested book is out of stock.") + return super().validate(attrs) + + +class FullBookLoanSerializer(BaseBookLoanSerializer): class Meta: model = BookLoan - fields = ['id', 'user', 'book', 'status', 'created_at', 'date_borrowed', 'date_due', 'date_returned'] - read_only_fields = ('user', 'created_at') + fields = [ + "id", + "user", + "book", + "status", + "created_at", + "date_borrowed", + "date_due", + "date_returned", + ] + read_only_fields = ("user", "created_at") -class BasicBookLoanSerializer(serializers.ModelSerializer): +class UserBookLoanSerializer(BaseBookLoanSerializer): class Meta: model = BookLoan - fields = ['id', 'user', 'book', 'status', 'created_at', 'date_borrowed', 'date_due', 'date_returned'] - read_only_fields = ('user', 'status', 'created_at', 'date_borrowed', 'date_due', 'date_returned') + fields = [ + "id", + "user", + "book", + "status", + "created_at", + "date_borrowed", + "date_due", + "date_returned", + ] + read_only_fields = ( + "user", + "status", + "created_at", + "date_borrowed", + "date_due", + "date_returned", + ) diff --git a/core/signals.py b/core/signals.py index eacc9e5..f50951e 100644 --- a/core/signals.py +++ b/core/signals.py @@ -13,7 +13,7 @@ def update_inventory(sender, **kwargs): else: loan_previous = BookLoan.objects.get(id=loan_instance.id) if loan_previous.status != loan_instance.status: # status updated - if loan_instance.status == 'issued': + if loan_instance.status == "issued": Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") - 1) - elif loan_instance.status == 'returned': + elif loan_instance.status == "returned": Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") + 1) diff --git a/core/views.py b/core/views.py index 3e1f25f..44798a1 100644 --- a/core/views.py +++ b/core/views.py @@ -1,23 +1,24 @@ from rest_framework.filters import SearchFilter from rest_framework import viewsets -from rest_framework.permissions import SAFE_METHODS, AllowAny, IsAuthenticated, IsAdminUser +from rest_framework.permissions import IsAdminUser from .permissions import IsLibrarian, IsLoanOwner, ReadOnly from .models import Book, BookLoan -from .serializers import FullBookLoanSerializer, BasicBookLoanSerializer, BookSerializer +from .serializers import FullBookLoanSerializer, UserBookLoanSerializer, BookSerializer class BookViewSet(viewsets.ModelViewSet): queryset = Book.objects.all() serializer_class = BookSerializer filter_backends = [SearchFilter] - search_fields = ['name', 'author', 'publisher'] + search_fields = ["name", "author", "publisher"] permission_classes = [IsAdminUser | IsLibrarian | ReadOnly] class BookLoanViewSet(viewsets.ModelViewSet): queryset = BookLoan.objects.all() permission_classes = [IsAdminUser | IsLibrarian | IsLoanOwner | ReadOnly] + filterset_fields = ["book", "user", "status"] def get_queryset(self): user = self.request.user @@ -30,7 +31,7 @@ def get_queryset(self): def get_serializer_class(self): if self.request.user.is_librarian: return FullBookLoanSerializer - return BasicBookLoanSerializer + return UserBookLoanSerializer def perform_create(self, serializer): return serializer.save(user=self.request.user) diff --git a/requirements.txt b/requirements.txt index 0491e62..2317946 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Django==4.1.5 +django-filter==3.6.0 djangorestframework==3.14.0 djangorestframework-simplejwt==5.2.2 djoser==2.1.0 From 2ace28d7aee7392abef99fd99a5b55ff1b58b2fd Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Tue, 24 Jan 2023 16:17:48 +0500 Subject: [PATCH 08/23] feat: add import csv data command BREAKING: chnage book_cover type from file to URL --- .gitignore | 1 + core/management/__init__.py | 0 core/management/commands/__init__.py | 0 .../commands/import_books_from_csv.py | 45 +++++++++++++++++++ core/migrations/0001_initial.py | 4 +- core/migrations/0002_alter_book_cover.py | 18 -------- core/models.py | 2 +- 7 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 core/management/__init__.py create mode 100644 core/management/commands/__init__.py create mode 100644 core/management/commands/import_books_from_csv.py delete mode 100644 core/migrations/0002_alter_book_cover.py diff --git a/.gitignore b/.gitignore index 77dc6ba..97c2ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ GitHub.sublime-settings .history uploads/ +files/ \ No newline at end of file diff --git a/core/management/__init__.py b/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/import_books_from_csv.py b/core/management/commands/import_books_from_csv.py new file mode 100644 index 0000000..e9d00fb --- /dev/null +++ b/core/management/commands/import_books_from_csv.py @@ -0,0 +1,45 @@ +import csv +from typing import Any, Optional + +from django.core.management.base import BaseCommand, CommandParser + +from core.serializers import BookSerializer +from core.models import Book + + +class Command(BaseCommand): + help = "Import books form csv" + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument("file_path", nargs=1, type=str) + + def handle(self, *args: Any, **options: Any) -> Optional[str]: + self.file_path = options['file_path'][0] + self.prepare() + self.main() + self.finalize() + + def prepare(self): + self.imported_counter = 0 + self.skipped_counter = 0 + + def main(self): + self.stdout.write("=== Importing Books ===\n\n") + + with open(self.file_path, 'r') as f: + reader = csv.DictReader(f) + + for index, row in enumerate(reader): + serializer = BookSerializer(data=row) + if serializer.is_valid(): + self.imported_counter += 1 + serializer.save() + self.stdout.write(f'{index} {row["name"]} SAVED') + else: + self.skipped_counter += 1 + self.stdout.write(f'{index} {row["name"]} SKIPPED {serializer.errors}') + + def finalize(self): + self.stdout.write("----------------------") + self.stdout.write(f"Books imported: {self.imported_counter}") + self.stdout.write(f"Books skipped: {self.skipped_counter}") diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 5b20cbd..99e7671 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.5 on 2023-01-23 08:08 +# Generated by Django 4.1.5 on 2023-01-24 10:39 from django.conf import settings import django.contrib.auth.models @@ -110,7 +110,7 @@ class Migration(migrations.Migration): ), ), ("name", models.CharField(max_length=100)), - ("cover", models.ImageField(upload_to="books/")), + ("cover", models.URLField()), ("author", models.CharField(max_length=100)), ("publisher", models.CharField(max_length=100)), ("stock", models.IntegerField()), diff --git a/core/migrations/0002_alter_book_cover.py b/core/migrations/0002_alter_book_cover.py deleted file mode 100644 index de2fdf5..0000000 --- a/core/migrations/0002_alter_book_cover.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.5 on 2023-01-24 10:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="book", - name="cover", - field=models.ImageField(upload_to="files/"), - ), - ] diff --git a/core/models.py b/core/models.py index 356284a..079661f 100644 --- a/core/models.py +++ b/core/models.py @@ -19,7 +19,7 @@ def is_librarian(self): class Book(models.Model): name = models.CharField(max_length=100) - cover = models.ImageField(upload_to='files/') + cover = models.URLField(max_length=200) author = models.CharField(max_length=100) publisher = models.CharField(max_length=100) stock = models.IntegerField() From a008a622bd4a07e45264846c805531ab06db03b1 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Tue, 24 Jan 2023 18:28:22 +0500 Subject: [PATCH 09/23] cron job setup --- .gitignore | 3 +- core/cron.py | 5 ++ core/serializers.py | 3 ++ lms/settings.py | 110 ++++++++++++++++++++++++++------------------ requirements.txt | 1 + 5 files changed, 76 insertions(+), 46 deletions(-) create mode 100644 core/cron.py diff --git a/.gitignore b/.gitignore index 97c2ddc..73838bd 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,5 @@ GitHub.sublime-settings .history uploads/ -files/ \ No newline at end of file +files/ +logs/ diff --git a/core/cron.py b/core/cron.py new file mode 100644 index 0000000..9144536 --- /dev/null +++ b/core/cron.py @@ -0,0 +1,5 @@ +from datetime import datetime + + +def email_overdue_books(): + print(datetime.now()) diff --git a/core/serializers.py b/core/serializers.py index f41fa8b..885768d 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -15,8 +15,11 @@ class BaseBookLoanSerializer(serializers.ModelSerializer): def validate(self, attrs): loan = BookLoan(**attrs) book = Book.objects.get(pk=loan.book.id) + + # requested book should be available if book.stock == 0: raise serializers.ValidationError("Requested book is out of stock.") + return super().validate(attrs) diff --git a/lms/settings.py b/lms/settings.py index cc27b33..20ac3cb 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/ """ from datetime import timedelta +import os from pathlib import Path import environ @@ -24,7 +25,7 @@ # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-9!as*52q-*k&4r#&*$5l7e7s$!x78!^1s^q+4kl*vnv=qr8gx+' +SECRET_KEY = "django-insecure-9!as*52q-*k&4r#&*$5l7e7s$!x78!^1s^q+4kl*vnv=qr8gx+" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -35,47 +36,48 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'django_filters', - 'djoser', + "django_crontab", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "django_filters", + "djoser", "core.apps.CoreConfig", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'lms.urls' +ROOT_URLCONF = "lms.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'lms.wsgi.application' +WSGI_APPLICATION = "lms.wsgi.application" # Database @@ -84,11 +86,11 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", - "NAME": env("NAME"), - "USER": env("USER"), - "PASSWORD": env("PASSWORD"), - "HOST": env("HOST"), - "PORT": env("PORT"), + "NAME": env("DB_NAME"), + "USER": env("DB_USER"), + "PASSWORD": env("DB_PASSWORD"), + "HOST": env("DB_HOST"), + "PORT": env("DB_PORT"), } } @@ -98,16 +100,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -115,9 +117,9 @@ # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -127,21 +129,39 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.1/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -AUTH_USER_MODEL = 'core.User' +AUTH_USER_MODEL = "core.User" REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], - 'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework_simplejwt.authentication.JWTAuthentication'], + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"], } SIMPLE_JWT = { - 'AUTH_HEADER_TYPES': ('JWT',), + "AUTH_HEADER_TYPES": ("JWT",), "ACCESS_TOKEN_LIFETIME": timedelta(days=1), } + + +# Email settings +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = env("EMAIL_HOST") +EMAIL_PORT = env("EMAIL_PORT") +EMAIL_HOST_USER = env("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = True + + +CRONJOBS = [ + ( + "0 10 */1 * *", + "core.cron.email_overdue_books", + ">> " + os.path.join(BASE_DIR, "logs/cron_logs/cron_email_overdue_books.log" + " 2>&1 "), + ) +] diff --git a/requirements.txt b/requirements.txt index 2317946..8347c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ djoser==2.1.0 django-environ==0.9.0 psycopg2==2.9.5 pillow==9.4 +django-crontab==0.7.1 From 8754897ef65f895b217ea2ee4f1ee69a7199cd0d Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Wed, 25 Jan 2023 18:11:56 +0500 Subject: [PATCH 10/23] feat: send reminder emails --- .gitignore | 1 + core/admin.py | 4 ++-- core/cron.py | 23 +++++++++++++++++- core/migrations/0001_initial.py | 10 ++++---- core/models.py | 1 + core/permissions.py | 2 +- core/templates/emails/overdue_books.html | 12 ++++++++++ core/urls.py | 2 +- core/views.py | 30 ++++++++++++++++++------ lms/settings.py | 10 ++++---- 10 files changed, 73 insertions(+), 22 deletions(-) create mode 100644 core/templates/emails/overdue_books.html diff --git a/.gitignore b/.gitignore index 73838bd..f2a8084 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ GitHub.sublime-settings uploads/ files/ logs/ +sandbox.py diff --git a/core/admin.py b/core/admin.py index f99c0ca..214a57e 100644 --- a/core/admin.py +++ b/core/admin.py @@ -5,10 +5,10 @@ @admin.register(Librarian) -class UserAdmin(admin.ModelAdmin): +class LibrarianAdmin(admin.ModelAdmin): pass @admin.register(User) class UserAdmin(BaseUserAdmin): - pass + add_fieldsets = ((None, {'classes': ('wide',), 'fields': ('username', 'email', 'password1', 'password2')}),) diff --git a/core/cron.py b/core/cron.py index 9144536..03bca36 100644 --- a/core/cron.py +++ b/core/cron.py @@ -1,5 +1,26 @@ +from templated_mail.mail import BaseEmailMessage + from datetime import datetime +from core.models import BookLoan + def email_overdue_books(): - print(datetime.now()) + today = datetime.now() + loan_queryset = BookLoan.objects.select_related("book").select_related('user').filter(date_due__lt=today) + + for loan in loan_queryset: + try: + # send_mail(subject, message, from_email, recipient_list.append(loan.user.email)) + message = BaseEmailMessage( + template_name="emails/overdue_books.html", + context={ + "name": loan.user, + "book": loan.book, + }, + ) + message.send([loan.user.email]) + print(today, loan.user.email, loan.book, loan.date_due) + + except: + print("failed") diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 99e7671..8b23240 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.5 on 2023-01-24 10:39 +# Generated by Django 4.1.5 on 2023-01-25 09:35 from django.conf import settings import django.contrib.auth.models @@ -61,10 +61,6 @@ class Migration(migrations.Migration): "last_name", models.CharField(blank=True, max_length=150, verbose_name="last name"), ), - ( - "email", - models.EmailField(blank=True, max_length=254, verbose_name="email address"), - ), ( "is_staff", models.BooleanField( @@ -85,6 +81,10 @@ class Migration(migrations.Migration): "date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"), ), + ( + "email", + models.EmailField(max_length=254, unique=True, verbose_name="email address"), + ), ("phone_number", models.CharField(blank=True, max_length=20)), ("gender", models.CharField(blank=True, max_length=10)), ], diff --git a/core/models.py b/core/models.py index 079661f..f34bb99 100644 --- a/core/models.py +++ b/core/models.py @@ -4,6 +4,7 @@ class User(AbstractUser): + email = models.EmailField(_("email address"), unique=True, blank=False, null=False) phone_number = models.CharField(max_length=20, blank=True) gender = models.CharField(max_length=10, blank=True) books = models.ManyToManyField('Book', through='BookLoan', related_name='users') diff --git a/core/permissions.py b/core/permissions.py index ca57217..1cdff16 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -4,7 +4,7 @@ class IsLibrarian(BasePermission): def has_permission(self, request, view): - return request.user.is_librarian + return bool(request.user.is_authenticated and request.user.is_librarian) class ReadOnly(BasePermission): diff --git a/core/templates/emails/overdue_books.html b/core/templates/emails/overdue_books.html new file mode 100644 index 0000000..b14a7b8 --- /dev/null +++ b/core/templates/emails/overdue_books.html @@ -0,0 +1,12 @@ +{% block subject %}LMS Book Overdue{% endblock %} {% block html_body %} +

Hello {{ name }},

+

+ Your book {{ book }} is over due. Please return it at your earliest + convenience. +
+
+ Thank you
+ LMS +

+ +

{% endblock %}

diff --git a/core/urls.py b/core/urls.py index d72bd99..0713347 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,7 +4,7 @@ router = DefaultRouter() router.register(r"books", BookViewSet) -router.register(r"loans", BookLoanViewSet) +router.register(r"loans", BookLoanViewSet, basename='BookLoan') urlpatterns = [ path("", include(router.urls)), diff --git a/core/views.py b/core/views.py index 44798a1..42938c3 100644 --- a/core/views.py +++ b/core/views.py @@ -1,8 +1,12 @@ from rest_framework.filters import SearchFilter from rest_framework import viewsets -from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.decorators import action +from rest_framework.response import Response -from .permissions import IsLibrarian, IsLoanOwner, ReadOnly +from templated_mail.mail import BaseEmailMessage + +from .permissions import IsLibrarian, ReadOnly from .models import Book, BookLoan from .serializers import FullBookLoanSerializer, UserBookLoanSerializer, BookSerializer @@ -16,22 +20,34 @@ class BookViewSet(viewsets.ModelViewSet): class BookLoanViewSet(viewsets.ModelViewSet): - queryset = BookLoan.objects.all() - permission_classes = [IsAdminUser | IsLibrarian | IsLoanOwner | ReadOnly] + permission_classes = [IsAuthenticated] filterset_fields = ["book", "user", "status"] def get_queryset(self): user = self.request.user - - if user.is_staff or user.is_librarian: + if user.is_superuser or user.is_librarian: return BookLoan.objects.all() return BookLoan.objects.filter(user_id=user.id) def get_serializer_class(self): - if self.request.user.is_librarian: + user = self.request.user + if user.is_librarian: return FullBookLoanSerializer return UserBookLoanSerializer def perform_create(self, serializer): return serializer.save(user=self.request.user) + + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | IsLibrarian]) + def remind(self, request, pk): + loan = BookLoan.objects.get(pk=pk) + message = BaseEmailMessage( + template_name="emails/overdue_books.html", + context={ + "name": loan.user, + "book": loan.book, + }, + ) + message.send([loan.user.email]) + return Response({"detail": "reminder email sent to user"}) diff --git a/lms/settings.py b/lms/settings.py index 20ac3cb..8816626 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -151,17 +151,17 @@ # Email settings EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = env("EMAIL_HOST") -EMAIL_PORT = env("EMAIL_PORT") +EMAIL_HOST = "localhost" +EMAIL_PORT = 2525 EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = True - +EMAIL_USE_TLS = False +DEFAULT_FROM_EMAIL = "lms@lms.com" CRONJOBS = [ ( "0 10 */1 * *", "core.cron.email_overdue_books", - ">> " + os.path.join(BASE_DIR, "logs/cron_logs/cron_email_overdue_books.log" + " 2>&1 "), + ">> " + os.path.join(BASE_DIR, "logs/cron_logs/email_overdue_books.log" + " 2>&1 "), ) ] From 98ec4928058207443f0f02b0b0a8ee6e6afb5b17 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Wed, 25 Jan 2023 18:31:01 +0500 Subject: [PATCH 11/23] feat: add book request api --- core/serializers.py | 36 +++++++++++++++++++++++++++++++++++- core/urls.py | 3 ++- core/views.py | 31 +++++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/core/serializers.py b/core/serializers.py index 885768d..5ec82d7 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,6 +1,6 @@ from django.db.models import Q from rest_framework import serializers -from .models import Book, BookLoan +from .models import Book, BookLoan, BookRequest class BookSerializer(serializers.ModelSerializer): @@ -60,3 +60,37 @@ class Meta: "date_due", "date_returned", ) + + +class FullBookRequestSerializer(serializers.ModelSerializer): + class Meta: + model = BookRequest + fields = [ + "id", + "user", + "book_name", + "status", + "created_at", + "reason", + ] + read_only_fields = ("id", "user", "created_at") + + +class UserBookRequestSerializer(serializers.ModelSerializer): + class Meta: + model = BookRequest + fields = [ + "id", + "user", + "book_name", + "status", + "created_at", + "reason", + ] + read_only_fields = ( + "id", + "user", + "status", + "created_at", + "reason", + ) diff --git a/core/urls.py b/core/urls.py index 0713347..b42572a 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,10 +1,11 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from core.views import BookLoanViewSet, BookViewSet +from core.views import BookLoanViewSet, BookRequestViewSet, BookViewSet router = DefaultRouter() router.register(r"books", BookViewSet) router.register(r"loans", BookLoanViewSet, basename='BookLoan') +router.register(r"book_requests", BookRequestViewSet, basename='BookRequest') urlpatterns = [ path("", include(router.urls)), diff --git a/core/views.py b/core/views.py index 42938c3..7b23e50 100644 --- a/core/views.py +++ b/core/views.py @@ -7,8 +7,14 @@ from templated_mail.mail import BaseEmailMessage from .permissions import IsLibrarian, ReadOnly -from .models import Book, BookLoan -from .serializers import FullBookLoanSerializer, UserBookLoanSerializer, BookSerializer +from .models import Book, BookLoan, BookRequest +from .serializers import ( + FullBookLoanSerializer, + FullBookRequestSerializer, + UserBookLoanSerializer, + BookSerializer, + UserBookRequestSerializer, +) class BookViewSet(viewsets.ModelViewSet): @@ -51,3 +57,24 @@ def remind(self, request, pk): ) message.send([loan.user.email]) return Response({"detail": "reminder email sent to user"}) + + +class BookRequestViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + filterset_fields = ["user", "status"] + + def get_queryset(self): + user = self.request.user + if user.is_superuser or user.is_librarian: + return BookRequest.objects.all() + + return BookRequest.objects.filter(user_id=user.id) + + def get_serializer_class(self): + user = self.request.user + if user.is_librarian: + return FullBookRequestSerializer + return UserBookRequestSerializer + + def perform_create(self, serializer): + return serializer.save(user=self.request.user) From 23821e521ce0436050f456959d35487d17dc32af Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Thu, 26 Jan 2023 12:22:25 +0500 Subject: [PATCH 12/23] feat: BookRequest signal notify user for rejected book request --- README.md | 12 +++------ core/serializers.py | 25 +++++++++++++++++-- core/signals.py | 24 +++++++++++++++++- .../emails/book_request_rejected.html | 14 +++++++++++ lms/settings.py | 3 +++ lms/urls.py | 4 +++ requirements.txt | 1 + 7 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 core/templates/emails/book_request_rejected.html diff --git a/README.md b/README.md index 97bc89b..f2c0c8b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ You first need an env ☘️ ```python # create the env -conda create --name libraryenv +conda create --name lms # install requirements pip install -r requirements.txt @@ -19,6 +19,8 @@ accordingly. Otherwise you just need a .env file with DB credentials. Migrate models to your db ```python +python manage.py makemigrations + python manage.py migrate ``` @@ -27,11 +29,3 @@ Start the app ```python python manage.py runserver ``` - -### Documentation 📝: - -Complete docs of the API are available at the endpoint: - -``` -http://{HOST}/docs -``` diff --git a/core/serializers.py b/core/serializers.py index 5ec82d7..55ede97 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -20,6 +20,12 @@ def validate(self, attrs): if book.stock == 0: raise serializers.ValidationError("Requested book is out of stock.") + if loan.status == "issued": + if not loan.date_borrowed: + raise serializers.ValidationError({"date_borrowed": "Borrow date is required when issuing a book."}) + if not loan.date_due: + raise serializers.ValidationError({"date_due": "Due date is required when issuing a book."}) + return super().validate(attrs) @@ -62,7 +68,15 @@ class Meta: ) -class FullBookRequestSerializer(serializers.ModelSerializer): +class BaseBookRequestSerializer(serializers.ModelSerializer): + def validate(self, data): + if data["status"] == "rejected" and not data["reason"]: + raise serializers.ValidationError({"reason": "Reason is required for rejected books."}) + + return super().validate(data) + + +class FullBookRequestSerializer(BaseBookRequestSerializer): class Meta: model = BookRequest fields = [ @@ -76,7 +90,7 @@ class Meta: read_only_fields = ("id", "user", "created_at") -class UserBookRequestSerializer(serializers.ModelSerializer): +class UserBookRequestSerializer(BaseBookRequestSerializer): class Meta: model = BookRequest fields = [ @@ -94,3 +108,10 @@ class Meta: "created_at", "reason", ) + + # def validate(self, data): + # print(data["status"]) + # if data["status"] == "rejected" and data["reason"] == "": + # raise serializers.ValidationError({"reason":"Reason is required for rejected books."}) + + # return super().validate(data) diff --git a/core/signals.py b/core/signals.py index f50951e..cd90d52 100644 --- a/core/signals.py +++ b/core/signals.py @@ -2,7 +2,9 @@ from django.dispatch import receiver from django.db.models import F -from .models import Book, BookLoan +from templated_mail.mail import BaseEmailMessage + +from .models import Book, BookLoan, BookRequest @receiver(pre_save, sender=BookLoan) @@ -17,3 +19,23 @@ def update_inventory(sender, **kwargs): Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") - 1) elif loan_instance.status == "returned": Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") + 1) + + +@receiver(pre_save, sender=BookRequest) +def notify_user(sender, **kwargs): + request_instance: BookRequest = kwargs["instance"] + if request_instance.id is None: # new object will be created + pass + else: + loan_previous = BookRequest.objects.get(id=request_instance.id) + if loan_previous.status != request_instance.status: # status updated + if request_instance.status == "rejected": + message = BaseEmailMessage( + template_name="emails/book_request_rejected.html", + context={ + "name": request_instance.user, + "book": request_instance.book_name, + "reason": request_instance.reason, + }, + ) + message.send([request_instance.user.email]) diff --git a/core/templates/emails/book_request_rejected.html b/core/templates/emails/book_request_rejected.html new file mode 100644 index 0000000..0bc9827 --- /dev/null +++ b/core/templates/emails/book_request_rejected.html @@ -0,0 +1,14 @@ +{% block subject %}LMS Book Request Rejected{% endblock %} {% block html_body %} +

Hello {{ name }},

+

+ Your request for the book {{ book }} could not be completed at this moment. Due to the reason mentioned below: +
+
+

{{reason}}

+
+
+ Thank you
+ LMS +

+ +

{% endblock %}

diff --git a/lms/settings.py b/lms/settings.py index 8816626..29cf5e3 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -46,12 +46,14 @@ "rest_framework", "django_filters", "djoser", + "corsheaders", "core.apps.CoreConfig", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -139,6 +141,7 @@ AUTH_USER_MODEL = "core.User" REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"], } diff --git a/lms/urls.py b/lms/urls.py index e3f599a..3e0b980 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -16,9 +16,13 @@ from django.contrib import admin from django.urls import path, include +from rest_framework.documentation import include_docs_urls + + urlpatterns = [ path('admin/', admin.site.urls), path('auth/', include('djoser.urls')), path('auth/', include('djoser.urls.jwt')), + path("docs/", include_docs_urls(title="Library Management System API")), path("", include("core.urls")), ] diff --git a/requirements.txt b/requirements.txt index 8347c54..9a6c966 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ django-environ==0.9.0 psycopg2==2.9.5 pillow==9.4 django-crontab==0.7.1 +django-cors-headers==3.13.0 From 0a5e9fbf7ba5e430ae4f9d31b421000dec4b171d Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 30 Jan 2023 17:35:47 +0500 Subject: [PATCH 13/23] add formatting and docstrings --- core/admin.py | 2 ++ core/cron.py | 28 +++++++++++++--------------- core/models.py | 7 +++++++ core/permissions.py | 3 ++- core/tests.py | 3 --- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/core/admin.py b/core/admin.py index 214a57e..30dee19 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,5 @@ +"""core admin panel""" + from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin diff --git a/core/cron.py b/core/cron.py index 03bca36..99e3ae5 100644 --- a/core/cron.py +++ b/core/cron.py @@ -1,26 +1,24 @@ -from templated_mail.mail import BaseEmailMessage +""" This module has all core app corns jobs""" from datetime import datetime +from templated_mail.mail import BaseEmailMessage + from core.models import BookLoan def email_overdue_books(): + """cron job for emailing user which have a book overdue""" today = datetime.now() loan_queryset = BookLoan.objects.select_related("book").select_related('user').filter(date_due__lt=today) for loan in loan_queryset: - try: - # send_mail(subject, message, from_email, recipient_list.append(loan.user.email)) - message = BaseEmailMessage( - template_name="emails/overdue_books.html", - context={ - "name": loan.user, - "book": loan.book, - }, - ) - message.send([loan.user.email]) - print(today, loan.user.email, loan.book, loan.date_due) - - except: - print("failed") + message = BaseEmailMessage( + template_name="emails/overdue_books.html", + context={ + "name": loan.user, + "book": loan.book, + }, + ) + message.send([loan.user.email]) + print(today, loan.user.email, loan.book, loan.date_due) diff --git a/core/models.py b/core/models.py index f34bb99..b3ccae9 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,5 @@ +"""Core model schemas""" + from django.db import models from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ @@ -11,6 +13,11 @@ class User(AbstractUser): @property def is_librarian(self): + """checks is the user is librarian or not + + Returns: + Bool: if the user is librarian. + """ try: Librarian.objects.get(user_id=self.id) return True diff --git a/core/permissions.py b/core/permissions.py index 1cdff16..ca16456 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -1,5 +1,6 @@ +"""Core permission classes""" + from rest_framework.permissions import BasePermission, SAFE_METHODS -from .models import Librarian class IsLibrarian(BasePermission): diff --git a/core/tests.py b/core/tests.py index 7ce503c..e69de29 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. From 8415527df8f25520d31e51b8dbb5d01a3e7e6337 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Tue, 31 Jan 2023 13:23:55 +0500 Subject: [PATCH 14/23] add docstrings --- .github/linters/.python-black | 18 ----- .pre-commit-config.yaml | 18 ----- core/admin.py | 14 +++- core/apps.py | 9 ++- core/cron.py | 2 +- core/migrations/0002_alter_user_gender.py | 22 ++++++ core/models.py | 57 ++++++++++---- core/permissions.py | 18 ++++- core/serializers.py | 92 +++++++++++++++++++---- core/signals.py | 18 ++++- core/urls.py | 7 +- core/views.py | 30 +++++++- lms/settings.py | 7 ++ 13 files changed, 238 insertions(+), 74 deletions(-) delete mode 100644 .github/linters/.python-black delete mode 100644 .pre-commit-config.yaml create mode 100644 core/migrations/0002_alter_user_gender.py diff --git a/.github/linters/.python-black b/.github/linters/.python-black deleted file mode 100644 index 5bd0a9c..0000000 --- a/.github/linters/.python-black +++ /dev/null @@ -1,18 +0,0 @@ -[tool.black] ---skip-string-normalization = true -line-length = 120 -include = '\.pyi?$' -exclude = ''' -/( - \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist - | migrations -)/ -''' \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index f21ca9b..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# See https://pre-commit.com/hooks.html for more hooks -# Install the pre-commit hooks below with -# 'pre-commit install' - -# Auto-update the version of the hooks with -# 'pre-commit autoupdate' - -# Run the hooks on all files with -# 'pre-commit run --all' - - -repos: -- repo: https://github.com/psf/black - rev: 22.12.0 - hooks: - - id: black - args: ['--config=.github/linters/.python-black'] - diff --git a/core/admin.py b/core/admin.py index 30dee19..7c96560 100644 --- a/core/admin.py +++ b/core/admin.py @@ -8,9 +8,19 @@ @admin.register(Librarian) class LibrarianAdmin(admin.ModelAdmin): - pass + """Registers Librarian model to admin panel""" @admin.register(User) class UserAdmin(BaseUserAdmin): - add_fieldsets = ((None, {'classes': ('wide',), 'fields': ('username', 'email', 'password1', 'password2')}),) + """Over ride user view in admin panel""" + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("username", "email", "password1", "password2"), + }, + ), + ) diff --git a/core/apps.py b/core/apps.py index df7e09f..599b429 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,9 +1,14 @@ +"""Core config of LMS""" + from django.apps import AppConfig class CoreConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'core' + """This class defines core app configs""" + + default_auto_field = "django.db.models.BigAutoField" + name = "core" def ready(self) -> None: + """Sets up imports and pre-requisites for this app""" import core.signals diff --git a/core/cron.py b/core/cron.py index 99e3ae5..f4d9d70 100644 --- a/core/cron.py +++ b/core/cron.py @@ -1,4 +1,4 @@ -""" This module has all core app corns jobs""" +""" This module has all core app corn jobs""" from datetime import datetime diff --git a/core/migrations/0002_alter_user_gender.py b/core/migrations/0002_alter_user_gender.py new file mode 100644 index 0000000..38d4094 --- /dev/null +++ b/core/migrations/0002_alter_user_gender.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.5 on 2023-01-31 07:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="gender", + field=models.CharField( + blank=True, + choices=[("m", "Male"), ("f", "Female"), ("other", "Other")], + max_length=20, + ), + ), + ] diff --git a/core/models.py b/core/models.py index b3ccae9..3f5aec3 100644 --- a/core/models.py +++ b/core/models.py @@ -6,10 +6,19 @@ class User(AbstractUser): + """Override base User model""" + + class UserGender(models.TextChoices): + """Enumeration class for user gender""" + + MALE = "m", _("Male") + FEMALE = "f", _("Female") + OTHER = "other", _("Other") + email = models.EmailField(_("email address"), unique=True, blank=False, null=False) phone_number = models.CharField(max_length=20, blank=True) - gender = models.CharField(max_length=10, blank=True) - books = models.ManyToManyField('Book', through='BookLoan', related_name='users') + gender = models.CharField(max_length=20, choices=UserGender.choices, blank=True) + books = models.ManyToManyField("Book", through="BookLoan", related_name="users") @property def is_librarian(self): @@ -26,6 +35,12 @@ def is_librarian(self): class Book(models.Model): + """ + Books in the lms are represented by this model. + + All fields are required. Stock means the number of books available in the library. + """ + name = models.CharField(max_length=100) cover = models.URLField(max_length=200) author = models.CharField(max_length=100) @@ -37,6 +52,8 @@ def __str__(self): class Librarian(models.Model): + """Creates a child table for librarians for managing permission""" + user = models.OneToOneField(User, on_delete=models.CASCADE) def __str__(self): @@ -44,35 +61,49 @@ def __str__(self): class BookLoan(models.Model): + """This model represents user book loans. Loans are managed by librarians and admins.""" + class BookLoanStatus(models.TextChoices): - REQUESTED = 'requested', _('Requested') - ISSUED = 'issued', _('Issued') - REJECTED = 'rejected', _('Rejected') - RETURNED = 'returned', _('Returned') + """Enumeration class for book loans statues""" + + REQUESTED = "requested", _("Requested") + ISSUED = "issued", _("Issued") + REJECTED = "rejected", _("Rejected") + RETURNED = "returned", _("Returned") user = models.ForeignKey(User, on_delete=models.CASCADE) book = models.ForeignKey(Book, on_delete=models.CASCADE) - status = models.CharField(max_length=10, choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED) + status = models.CharField( + max_length=10, choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED + ) created_at = models.DateTimeField(auto_now_add=True) date_borrowed = models.DateField(null=True, blank=True) date_due = models.DateField(null=True, blank=True) date_returned = models.DateField(null=True, blank=True) def __str__(self): - return f'{self.user.username} - {self.book.name}' + return f"{self.user.username} - {self.book.name}" class BookRequest(models.Model): + """This model represents unavailable books requested by users""" + class BookRequestStatus(models.TextChoices): - PENDING = 'pending', _('Pending') - APPROVED = 'approved', _('Approved') - REJECTED = 'rejected', _('Rejected') + """Enumeration class for book request statues""" + + PENDING = "pending", _("Pending") + APPROVED = "approved", _("Approved") + REJECTED = "rejected", _("Rejected") user = models.ForeignKey(User, on_delete=models.CASCADE) book_name = models.CharField(max_length=100) - status = models.CharField(max_length=20, choices=BookRequestStatus.choices, default=BookRequestStatus.PENDING) + status = models.CharField( + max_length=20, + choices=BookRequestStatus.choices, + default=BookRequestStatus.PENDING, + ) created_at = models.DateTimeField(auto_now_add=True) reason = models.CharField(max_length=200, blank=True) def __str__(self): - return f'{self.user.username} - {self.book_name}' + return f"{self.user.username} - {self.book_name}" diff --git a/core/permissions.py b/core/permissions.py index ca16456..d3a5371 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -1,18 +1,32 @@ -"""Core permission classes""" +""" +Core permission classes +""" from rest_framework.permissions import BasePermission, SAFE_METHODS class IsLibrarian(BasePermission): + """ + Allows access to only librarians + """ + def has_permission(self, request, view): return bool(request.user.is_authenticated and request.user.is_librarian) class ReadOnly(BasePermission): + """ + Allows access to read-only requests + """ + def has_permission(self, request, view): return request.method in SAFE_METHODS -class IsLoanOwner(BasePermission): +class IsOwner(BasePermission): + """ + Allows access only to owners + """ + def has_object_permission(self, request, view, obj): return obj.user == request.user diff --git a/core/serializers.py b/core/serializers.py index 55ede97..73fd7df 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,15 +1,54 @@ +""" +Core app serializers +""" from django.db.models import Q from rest_framework import serializers + +from djoser.serializers import ( + UserSerializer as BaseUserSerializer, + UserCreateSerializer as BaseUserCreateSerializer, +) + from .models import Book, BookLoan, BookRequest +class UserCreateSerializer(BaseUserCreateSerializer): + """ + User Create serializer. Adds user profile fields when creating a user. + """ + + class Meta(BaseUserCreateSerializer.Meta): + fields = ["id", "username", "password", "email", "phone_number", "gender"] + + +class CurrentUserSerializer(BaseUserSerializer): + """ + Current User serializer. Adds user profile fields when getting a user. + """ + + class Meta(BaseUserSerializer.Meta): + fields = ["id", "username", "email", "phone_number", "gender"] + + class BookSerializer(serializers.ModelSerializer): + """ + Serializer for Books + """ + class Meta: model = Book fields = ["id", "name", "cover", "author", "publisher", "stock"] class BaseBookLoanSerializer(serializers.ModelSerializer): + """ + Base serializer for book loans. It makes sure: + + * All book loans only have available books for loan. + * Issued books have: date_borrowed and date_due. + * Returned books have date_returned. + """ + book = serializers.PrimaryKeyRelatedField(queryset=Book.objects.filter(~Q(stock=0))) def validate(self, attrs): @@ -22,14 +61,27 @@ def validate(self, attrs): if loan.status == "issued": if not loan.date_borrowed: - raise serializers.ValidationError({"date_borrowed": "Borrow date is required when issuing a book."}) + raise serializers.ValidationError( + {"date_borrowed": "Borrow date is required when issuing a book."} + ) if not loan.date_due: - raise serializers.ValidationError({"date_due": "Due date is required when issuing a book."}) + raise serializers.ValidationError( + {"date_due": "Due date is required when issuing a book."} + ) + + if loan.status == "returned" and not loan.date_returned: + raise serializers.ValidationError( + {"date_returned": "Returned date is required when returning a book."} + ) return super().validate(attrs) class FullBookLoanSerializer(BaseBookLoanSerializer): + """ + Book loans serializer for librarians and admin. + """ + class Meta: model = BookLoan fields = [ @@ -46,6 +98,10 @@ class Meta: class UserBookLoanSerializer(BaseBookLoanSerializer): + """ + Book Loan serializer for users. + """ + class Meta: model = BookLoan fields = [ @@ -69,14 +125,27 @@ class Meta: class BaseBookRequestSerializer(serializers.ModelSerializer): - def validate(self, data): - if data["status"] == "rejected" and not data["reason"]: - raise serializers.ValidationError({"reason": "Reason is required for rejected books."}) + """ + Base book request serializer from which all book request serializers must inherit. It makes sure that: + + * Admins and librarian provide reason for rejecting a book request. - return super().validate(data) + """ + + def validate(self, attrs): + if attrs["status"] == "rejected" and not attrs["reason"]: + raise serializers.ValidationError( + {"reason": "Reason is required for rejected books."} + ) + + return super().validate(attrs) class FullBookRequestSerializer(BaseBookRequestSerializer): + """ + Book request serializer for admins and librarians. + """ + class Meta: model = BookRequest fields = [ @@ -91,6 +160,10 @@ class Meta: class UserBookRequestSerializer(BaseBookRequestSerializer): + """ + Book request serializer for Users. + """ + class Meta: model = BookRequest fields = [ @@ -108,10 +181,3 @@ class Meta: "created_at", "reason", ) - - # def validate(self, data): - # print(data["status"]) - # if data["status"] == "rejected" and data["reason"] == "": - # raise serializers.ValidationError({"reason":"Reason is required for rejected books."}) - - # return super().validate(data) diff --git a/core/signals.py b/core/signals.py index cd90d52..bdef1a1 100644 --- a/core/signals.py +++ b/core/signals.py @@ -1,3 +1,7 @@ +""" +Signals for Core app +""" + from django.db.models.signals import pre_save from django.dispatch import receiver from django.db.models import F @@ -9,6 +13,9 @@ @receiver(pre_save, sender=BookLoan) def update_inventory(sender, **kwargs): + """ + A Bookloan signal for updating book inventory for every new loan issue or upon return. + """ loan_instance: BookLoan = kwargs["instance"] if loan_instance.id is None: # new object will be created pass @@ -16,13 +23,20 @@ def update_inventory(sender, **kwargs): loan_previous = BookLoan.objects.get(id=loan_instance.id) if loan_previous.status != loan_instance.status: # status updated if loan_instance.status == "issued": - Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") - 1) + Book.objects.filter(pk=loan_instance.book.id).update( + stock=F("stock") - 1 + ) elif loan_instance.status == "returned": - Book.objects.filter(pk=loan_instance.book.id).update(stock=F("stock") + 1) + Book.objects.filter(pk=loan_instance.book.id).update( + stock=F("stock") + 1 + ) @receiver(pre_save, sender=BookRequest) def notify_user(sender, **kwargs): + """ + A signal that notifies user when their book request is rejected. + """ request_instance: BookRequest = kwargs["instance"] if request_instance.id is None: # new object will be created pass diff --git a/core/urls.py b/core/urls.py index b42572a..edbcdef 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,11 +1,14 @@ +""" +Urls for Core app +""" from django.urls import path, include from rest_framework.routers import DefaultRouter from core.views import BookLoanViewSet, BookRequestViewSet, BookViewSet router = DefaultRouter() router.register(r"books", BookViewSet) -router.register(r"loans", BookLoanViewSet, basename='BookLoan') -router.register(r"book_requests", BookRequestViewSet, basename='BookRequest') +router.register(r"loans", BookLoanViewSet, basename="BookLoan") +router.register(r"book_requests", BookRequestViewSet, basename="BookRequest") urlpatterns = [ path("", include(router.urls)), diff --git a/core/views.py b/core/views.py index 7b23e50..b1634ab 100644 --- a/core/views.py +++ b/core/views.py @@ -1,3 +1,7 @@ +""" +Views for core app +""" + from rest_framework.filters import SearchFilter from rest_framework import viewsets from rest_framework.permissions import IsAdminUser, IsAuthenticated @@ -18,6 +22,10 @@ class BookViewSet(viewsets.ModelViewSet): + """ + Book viewset + """ + queryset = Book.objects.all() serializer_class = BookSerializer filter_backends = [SearchFilter] @@ -26,6 +34,10 @@ class BookViewSet(viewsets.ModelViewSet): class BookLoanViewSet(viewsets.ModelViewSet): + """ + Book loan viewset. It only lists users own loans or all loans for admins and librarians. + """ + permission_classes = [IsAuthenticated] filterset_fields = ["book", "user", "status"] @@ -45,8 +57,20 @@ def get_serializer_class(self): def perform_create(self, serializer): return serializer.save(user=self.request.user) - @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | IsLibrarian]) + @action( + detail=True, methods=["get"], permission_classes=[IsAdminUser | IsLibrarian] + ) def remind(self, request, pk): + """ + Reminds user, by email, for their outstanding loan. + + Args: + request: Request object + pk: primary key for BookLoan + + Returns: + dict: detail message + """ loan = BookLoan.objects.get(pk=pk) message = BaseEmailMessage( template_name="emails/overdue_books.html", @@ -60,6 +84,10 @@ def remind(self, request, pk): class BookRequestViewSet(viewsets.ModelViewSet): + """ + Book request model viewset for requesting unavailable books. + """ + permission_classes = [IsAuthenticated] filterset_fields = ["user", "status"] diff --git a/lms/settings.py b/lms/settings.py index 29cf5e3..6f80030 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -140,6 +140,13 @@ AUTH_USER_MODEL = "core.User" +DJOSER = { + 'SERIALIZERS': { + 'user_create': 'core.serializers.UserCreateSerializer', + 'current_user': 'core.serializers.CurrentUserSerializer', + } +} + REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], From 1c38dcbe748195db8b86effd22ba0ab2ebeaa3f4 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 27 Feb 2023 12:03:23 +0500 Subject: [PATCH 15/23] Update select_related in core/cron.py Co-authored-by: Muhammad Abdullah Waheed <42172960+abdullahwaheed@users.noreply.github.com> --- core/cron.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/cron.py b/core/cron.py index f4d9d70..f8d3299 100644 --- a/core/cron.py +++ b/core/cron.py @@ -10,7 +10,7 @@ def email_overdue_books(): """cron job for emailing user which have a book overdue""" today = datetime.now() - loan_queryset = BookLoan.objects.select_related("book").select_related('user').filter(date_due__lt=today) + loan_queryset = BookLoan.objects.select_related('book', 'user').filter(date_due__lt=today) for loan in loan_queryset: message = BaseEmailMessage( From 2e507ea1d6f203d48b66c75e30a67c8474331e3e Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:43:18 +0500 Subject: [PATCH 16/23] model changes add related names change models choice fields type to small int --- .vscode/settings.json | 9 +++ ...oan_book_alter_bookloan_status_and_more.py | 66 +++++++++++++++++++ core/models.py | 39 ++++++----- lms/settings.py | 2 + 4 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..28cbe35 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.pylintArgs": [ + "--load-plugins=pylint_django", + "--max-line-length=120", + "--disable=django-not-configured", + "--django-settings-module=lms.settings" + ] +} diff --git a/core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py b/core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py new file mode 100644 index 0000000..8441d0f --- /dev/null +++ b/core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py @@ -0,0 +1,66 @@ +# Generated by Django 4.1.5 on 2023-02-27 09:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_alter_user_gender"), + ] + + operations = [ + migrations.AlterField( + model_name="bookloan", + name="book", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="loans", + to="core.book", + ), + ), + migrations.AlterField( + model_name="bookloan", + name="status", + field=models.SmallIntegerField( + choices=[ + ("0", "Requested"), + ("1", "Issued"), + ("2", "Rejected"), + ("3", "Returned"), + ], + default="0", + ), + ), + migrations.AlterField( + model_name="bookrequest", + name="status", + field=models.SmallIntegerField( + choices=[("0", "Pending"), ("1", "Approved"), ("2", "Rejected")], + default="0", + ), + ), + migrations.AlterField( + model_name="bookrequest", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="book_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="user", + name="books", + field=models.ManyToManyField(through="core.BookLoan", to="core.book"), + ), + migrations.AlterField( + model_name="user", + name="gender", + field=models.SmallIntegerField( + blank=True, choices=[("0", "Male"), ("1", "Female"), ("2", "Other")] + ), + ), + ] diff --git a/core/models.py b/core/models.py index 3f5aec3..6e308f5 100644 --- a/core/models.py +++ b/core/models.py @@ -11,14 +11,14 @@ class User(AbstractUser): class UserGender(models.TextChoices): """Enumeration class for user gender""" - MALE = "m", _("Male") - FEMALE = "f", _("Female") - OTHER = "other", _("Other") + MALE = 0, _("Male") + FEMALE = 1, _("Female") + OTHER = 2, _("Other") email = models.EmailField(_("email address"), unique=True, blank=False, null=False) phone_number = models.CharField(max_length=20, blank=True) - gender = models.CharField(max_length=20, choices=UserGender.choices, blank=True) - books = models.ManyToManyField("Book", through="BookLoan", related_name="users") + gender = models.SmallIntegerField(choices=UserGender.choices, blank=True) + books = models.ManyToManyField("Book", through="BookLoan") @property def is_librarian(self): @@ -48,7 +48,7 @@ class Book(models.Model): stock = models.IntegerField() def __str__(self): - return self.name + return str(self.name) class Librarian(models.Model): @@ -57,7 +57,7 @@ class Librarian(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) def __str__(self): - return self.user.username + return str(self.user.username) class BookLoan(models.Model): @@ -66,15 +66,15 @@ class BookLoan(models.Model): class BookLoanStatus(models.TextChoices): """Enumeration class for book loans statues""" - REQUESTED = "requested", _("Requested") - ISSUED = "issued", _("Issued") - REJECTED = "rejected", _("Rejected") - RETURNED = "returned", _("Returned") + REQUESTED = 0, _("Requested") + ISSUED = 1, _("Issued") + REJECTED = 2, _("Rejected") + RETURNED = 3, _("Returned") user = models.ForeignKey(User, on_delete=models.CASCADE) - book = models.ForeignKey(Book, on_delete=models.CASCADE) - status = models.CharField( - max_length=10, choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED + book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='loans') + status = models.SmallIntegerField( + choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED ) created_at = models.DateTimeField(auto_now_add=True) date_borrowed = models.DateField(null=True, blank=True) @@ -91,14 +91,13 @@ class BookRequest(models.Model): class BookRequestStatus(models.TextChoices): """Enumeration class for book request statues""" - PENDING = "pending", _("Pending") - APPROVED = "approved", _("Approved") - REJECTED = "rejected", _("Rejected") + PENDING = 0, _("Pending") + APPROVED = 1, _("Approved") + REJECTED = 2, _("Rejected") - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='book_requests') book_name = models.CharField(max_length=100) - status = models.CharField( - max_length=20, + status = models.SmallIntegerField( choices=BookRequestStatus.choices, default=BookRequestStatus.PENDING, ) diff --git a/lms/settings.py b/lms/settings.py index 6f80030..b30c1bc 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -175,3 +175,5 @@ ">> " + os.path.join(BASE_DIR, "logs/cron_logs/email_overdue_books.log" + " 2>&1 "), ) ] + +CORS_ORIGIN_ALLOW_ALL = True From 849c89f0ad91801572a246837cb0210276ddf1b9 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:51:49 +0500 Subject: [PATCH 17/23] change book filter query in core/serializers.py Co-authored-by: Muhammad Abdullah Waheed <42172960+abdullahwaheed@users.noreply.github.com> --- core/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/serializers.py b/core/serializers.py index 73fd7df..f9214a2 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -49,7 +49,7 @@ class BaseBookLoanSerializer(serializers.ModelSerializer): * Returned books have date_returned. """ - book = serializers.PrimaryKeyRelatedField(queryset=Book.objects.filter(~Q(stock=0))) + book = serializers.PrimaryKeyRelatedField(queryset=Book.objects.exclude(stock=0)) def validate(self, attrs): loan = BookLoan(**attrs) From eeda78f46032118663c7c33ca8d6a3e8f58277d4 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 27 Feb 2023 15:05:15 +0500 Subject: [PATCH 18/23] Update requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9a6c966..fb2635d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ django-environ==0.9.0 psycopg2==2.9.5 pillow==9.4 django-crontab==0.7.1 -django-cors-headers==3.13.0 +django-cors-headers==3.13. +django-templated-mail==1.1.1 From 2339d19e5a333a103ea691a760b19aa8cd79b04f Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 27 Feb 2023 15:58:28 +0500 Subject: [PATCH 19/23] add logging --- core/cron.py | 6 +++++- lms/settings.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/core/cron.py b/core/cron.py index f8d3299..9628ad7 100644 --- a/core/cron.py +++ b/core/cron.py @@ -1,12 +1,16 @@ """ This module has all core app corn jobs""" from datetime import datetime +import logging from templated_mail.mail import BaseEmailMessage from core.models import BookLoan +logger = logging.getLogger(__name__) + + def email_overdue_books(): """cron job for emailing user which have a book overdue""" today = datetime.now() @@ -21,4 +25,4 @@ def email_overdue_books(): }, ) message.send([loan.user.email]) - print(today, loan.user.email, loan.book, loan.date_due) + logger.info("email sent to %s for %s due: %s", loan.user.email, loan.book, loan.date_due) diff --git a/lms/settings.py b/lms/settings.py index b30c1bc..69c2f4a 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -177,3 +177,30 @@ ] CORS_ORIGIN_ALLOW_ALL = True + + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'simple': { + 'format': '{levelname} {asctime} {message}', + 'style': '{', + } + }, + 'handlers': { + 'info': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'formatter': 'simple', + 'filename': os.path.join(BASE_DIR, "logs/info.logs") + } + }, + 'loggers': { + '': { + 'handlers': ['info'], + 'level': 'INFO', + 'propagate': True + } + } +} From 5d4c4b2cd0f09726264e897ed8a3a68fab0ec678 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 27 Feb 2023 16:39:01 +0500 Subject: [PATCH 20/23] Feat!: remove librarian model --- core/admin.py | 7 +------ core/models.py | 15 ++------------- core/views.py | 6 +++--- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/core/admin.py b/core/admin.py index 7c96560..4af680a 100644 --- a/core/admin.py +++ b/core/admin.py @@ -3,12 +3,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from core.models import User, Librarian - - -@admin.register(Librarian) -class LibrarianAdmin(admin.ModelAdmin): - """Registers Librarian model to admin panel""" +from core.models import User @admin.register(User) diff --git a/core/models.py b/core/models.py index 6e308f5..228d918 100644 --- a/core/models.py +++ b/core/models.py @@ -27,11 +27,8 @@ def is_librarian(self): Returns: Bool: if the user is librarian. """ - try: - Librarian.objects.get(user_id=self.id) - return True - except Librarian.DoesNotExist: - return False + return self.groups.filter(name='librarian').exists() + class Book(models.Model): @@ -51,14 +48,6 @@ def __str__(self): return str(self.name) -class Librarian(models.Model): - """Creates a child table for librarians for managing permission""" - - user = models.OneToOneField(User, on_delete=models.CASCADE) - - def __str__(self): - return str(self.user.username) - class BookLoan(models.Model): """This model represents user book loans. Loans are managed by librarians and admins.""" diff --git a/core/views.py b/core/views.py index b1634ab..d32a433 100644 --- a/core/views.py +++ b/core/views.py @@ -4,13 +4,13 @@ from rest_framework.filters import SearchFilter from rest_framework import viewsets -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAdminUser, IsAuthenticated, DjangoModelPermissionsOrAnonReadOnly from rest_framework.decorators import action from rest_framework.response import Response from templated_mail.mail import BaseEmailMessage -from .permissions import IsLibrarian, ReadOnly +from .permissions import IsLibrarian from .models import Book, BookLoan, BookRequest from .serializers import ( FullBookLoanSerializer, @@ -30,7 +30,7 @@ class BookViewSet(viewsets.ModelViewSet): serializer_class = BookSerializer filter_backends = [SearchFilter] search_fields = ["name", "author", "publisher"] - permission_classes = [IsAdminUser | IsLibrarian | ReadOnly] + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] class BookLoanViewSet(viewsets.ModelViewSet): From 18ade71a7d1b268c4804c5ae5d7703dd6f99dde4 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Wed, 1 Mar 2023 17:33:50 +0500 Subject: [PATCH 21/23] add read serializers for book loans and request --- core/migrations/0001_initial.py | 93 ++++++++++--------- core/migrations/0002_alter_bookloan_status.py | 27 ++++++ ...oan_book_alter_bookloan_status_and_more.py | 66 ------------- ...tatus_alter_bookrequest_status_and_more.py | 50 ++++++++++ core/migrations/0004_alter_user_gender.py | 22 +++++ ...er_gender.py => 0005_alter_user_gender.py} | 9 +- core/models.py | 29 +++--- core/serializers.py | 21 ++++- core/signals.py | 29 +++--- core/views.py | 3 + lms/settings.py | 2 +- 11 files changed, 207 insertions(+), 144 deletions(-) create mode 100644 core/migrations/0002_alter_bookloan_status.py delete mode 100644 core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py create mode 100644 core/migrations/0003_alter_bookloan_status_alter_bookrequest_status_and_more.py create mode 100644 core/migrations/0004_alter_user_gender.py rename core/migrations/{0002_alter_user_gender.py => 0005_alter_user_gender.py} (57%) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 8b23240..f9f6a9c 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.5 on 2023-01-25 09:35 +# Generated by Django 4.1.5 on 2023-02-28 09:47 from django.conf import settings import django.contrib.auth.models @@ -32,7 +32,9 @@ class Migration(migrations.Migration): ("password", models.CharField(max_length=128, verbose_name="password")), ( "last_login", - models.DateTimeField(blank=True, null=True, verbose_name="last login"), + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), ), ( "is_superuser", @@ -45,21 +47,29 @@ class Migration(migrations.Migration): ( "username", models.CharField( - error_messages={"unique": "A user with that username already exists."}, + 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()], + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], verbose_name="username", ), ), ( "first_name", - models.CharField(blank=True, max_length=150, verbose_name="first name"), + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), ), ( "last_name", - models.CharField(blank=True, max_length=150, verbose_name="last name"), + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), ), ( "is_staff", @@ -79,14 +89,25 @@ class Migration(migrations.Migration): ), ( "date_joined", - models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"), + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), ), ( "email", - models.EmailField(max_length=254, unique=True, verbose_name="email address"), + models.EmailField( + max_length=254, unique=True, verbose_name="email address" + ), ), ("phone_number", models.CharField(blank=True, max_length=20)), - ("gender", models.CharField(blank=True, max_length=10)), + ( + "gender", + models.SmallIntegerField( + blank=True, + choices=[("0", "Male"), ("1", "Female"), ("2", "Other")], + null=True, + ), + ), ], options={ "verbose_name": "user", @@ -116,27 +137,6 @@ class Migration(migrations.Migration): ("stock", models.IntegerField()), ], ), - migrations.CreateModel( - name="Librarian", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), migrations.CreateModel( name="BookRequest", fields=[ @@ -152,14 +152,13 @@ class Migration(migrations.Migration): ("book_name", models.CharField(max_length=100)), ( "status", - models.CharField( + models.SmallIntegerField( choices=[ - ("pending", "Pending"), - ("approved", "Approved"), - ("rejected", "Rejected"), + ("0", "Pending"), + ("1", "Approved"), + ("2", "Rejected"), ], - default="pending", - max_length=20, + default="0", ), ), ("created_at", models.DateTimeField(auto_now_add=True)), @@ -168,6 +167,7 @@ class Migration(migrations.Migration): "user", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="book_requests", to=settings.AUTH_USER_MODEL, ), ), @@ -187,15 +187,14 @@ class Migration(migrations.Migration): ), ( "status", - models.CharField( + models.SmallIntegerField( choices=[ - ("requested", "Requested"), - ("issued", "Issued"), - ("rejected", "Rejected"), - ("returned", "Returned"), + ("0", "Requested"), + ("1", "Issued"), + ("2", "Rejected"), + ("3", "Returned"), ], - default="requested", - max_length=10, + default="0", ), ), ("created_at", models.DateTimeField(auto_now_add=True)), @@ -204,7 +203,11 @@ class Migration(migrations.Migration): ("date_returned", models.DateField(blank=True, null=True)), ( "book", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.book"), + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="loans", + to="core.book", + ), ), ( "user", @@ -218,7 +221,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="user", name="books", - field=models.ManyToManyField(related_name="users", through="core.BookLoan", to="core.book"), + field=models.ManyToManyField(through="core.BookLoan", to="core.book"), ), migrations.AddField( model_name="user", diff --git a/core/migrations/0002_alter_bookloan_status.py b/core/migrations/0002_alter_bookloan_status.py new file mode 100644 index 0000000..67c2296 --- /dev/null +++ b/core/migrations/0002_alter_bookloan_status.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.5 on 2023-02-28 09:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="bookloan", + name="status", + field=models.CharField( + choices=[ + ("0", "Requested"), + ("1", "Issued"), + ("2", "Rejected"), + ("3", "Returned"), + ], + default="0", + max_length=10, + ), + ), + ] diff --git a/core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py b/core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py deleted file mode 100644 index 8441d0f..0000000 --- a/core/migrations/0003_alter_bookloan_book_alter_bookloan_status_and_more.py +++ /dev/null @@ -1,66 +0,0 @@ -# Generated by Django 4.1.5 on 2023-02-27 09:34 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0002_alter_user_gender"), - ] - - operations = [ - migrations.AlterField( - model_name="bookloan", - name="book", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="loans", - to="core.book", - ), - ), - migrations.AlterField( - model_name="bookloan", - name="status", - field=models.SmallIntegerField( - choices=[ - ("0", "Requested"), - ("1", "Issued"), - ("2", "Rejected"), - ("3", "Returned"), - ], - default="0", - ), - ), - migrations.AlterField( - model_name="bookrequest", - name="status", - field=models.SmallIntegerField( - choices=[("0", "Pending"), ("1", "Approved"), ("2", "Rejected")], - default="0", - ), - ), - migrations.AlterField( - model_name="bookrequest", - name="user", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="book_requests", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterField( - model_name="user", - name="books", - field=models.ManyToManyField(through="core.BookLoan", to="core.book"), - ), - migrations.AlterField( - model_name="user", - name="gender", - field=models.SmallIntegerField( - blank=True, choices=[("0", "Male"), ("1", "Female"), ("2", "Other")] - ), - ), - ] diff --git a/core/migrations/0003_alter_bookloan_status_alter_bookrequest_status_and_more.py b/core/migrations/0003_alter_bookloan_status_alter_bookrequest_status_and_more.py new file mode 100644 index 0000000..f22a1ab --- /dev/null +++ b/core/migrations/0003_alter_bookloan_status_alter_bookrequest_status_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.1.5 on 2023-02-28 10:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_alter_bookloan_status"), + ] + + operations = [ + migrations.AlterField( + model_name="bookloan", + name="status", + field=models.CharField( + choices=[ + ("requested", "Requested"), + ("issued", "Issued"), + ("rejected", "Rejected"), + ("returned", "Returned"), + ], + default="requested", + max_length=10, + ), + ), + migrations.AlterField( + model_name="bookrequest", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], + default="pending", + max_length=10, + ), + ), + migrations.AlterField( + model_name="user", + name="gender", + field=models.CharField( + blank=True, + choices=[("male", "Male"), ("female", "Female"), ("other", "Other")], + max_length=10, + null=True, + ), + ), + ] diff --git a/core/migrations/0004_alter_user_gender.py b/core/migrations/0004_alter_user_gender.py new file mode 100644 index 0000000..f144428 --- /dev/null +++ b/core/migrations/0004_alter_user_gender.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.5 on 2023-02-28 10:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0003_alter_bookloan_status_alter_bookrequest_status_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="gender", + field=models.SmallIntegerField( + blank=True, + choices=[("0", "Male"), ("1", "Female"), ("2", "Other")], + null=True, + ), + ), + ] diff --git a/core/migrations/0002_alter_user_gender.py b/core/migrations/0005_alter_user_gender.py similarity index 57% rename from core/migrations/0002_alter_user_gender.py rename to core/migrations/0005_alter_user_gender.py index 38d4094..8091da6 100644 --- a/core/migrations/0002_alter_user_gender.py +++ b/core/migrations/0005_alter_user_gender.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.5 on 2023-01-31 07:15 +# Generated by Django 4.1.5 on 2023-02-28 10:44 from django.db import migrations, models @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("core", "0001_initial"), + ("core", "0004_alter_user_gender"), ] operations = [ @@ -15,8 +15,9 @@ class Migration(migrations.Migration): name="gender", field=models.CharField( blank=True, - choices=[("m", "Male"), ("f", "Female"), ("other", "Other")], - max_length=20, + choices=[("male", "Male"), ("female", "Female"), ("other", "Other")], + max_length=10, + null=True, ), ), ] diff --git a/core/models.py b/core/models.py index 228d918..03567e4 100644 --- a/core/models.py +++ b/core/models.py @@ -11,13 +11,13 @@ class User(AbstractUser): class UserGender(models.TextChoices): """Enumeration class for user gender""" - MALE = 0, _("Male") - FEMALE = 1, _("Female") - OTHER = 2, _("Other") + MALE = "male", _("Male") + FEMALE = "female", _("Female") + OTHER = "other", _("Other") email = models.EmailField(_("email address"), unique=True, blank=False, null=False) phone_number = models.CharField(max_length=20, blank=True) - gender = models.SmallIntegerField(choices=UserGender.choices, blank=True) + gender = models.CharField(max_length=10, choices=UserGender.choices, blank=True, null=True) books = models.ManyToManyField("Book", through="BookLoan") @property @@ -55,15 +55,15 @@ class BookLoan(models.Model): class BookLoanStatus(models.TextChoices): """Enumeration class for book loans statues""" - REQUESTED = 0, _("Requested") - ISSUED = 1, _("Issued") - REJECTED = 2, _("Rejected") - RETURNED = 3, _("Returned") + REQUESTED = "requested", _("Requested") + ISSUED = "issued", _("Issued") + REJECTED = "rejected", _("Rejected") + RETURNED = "returned", _("Returned") user = models.ForeignKey(User, on_delete=models.CASCADE) book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='loans') - status = models.SmallIntegerField( - choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED + status = models.CharField( + max_length=10, choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED ) created_at = models.DateTimeField(auto_now_add=True) date_borrowed = models.DateField(null=True, blank=True) @@ -80,13 +80,14 @@ class BookRequest(models.Model): class BookRequestStatus(models.TextChoices): """Enumeration class for book request statues""" - PENDING = 0, _("Pending") - APPROVED = 1, _("Approved") - REJECTED = 2, _("Rejected") + PENDING = "pending", _("Pending") + APPROVED = "approved", _("Approved") + REJECTED = "rejected", _("Rejected") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='book_requests') book_name = models.CharField(max_length=100) - status = models.SmallIntegerField( + status = models.CharField( + max_length=10, choices=BookRequestStatus.choices, default=BookRequestStatus.PENDING, ) diff --git a/core/serializers.py b/core/serializers.py index f9214a2..c69dc90 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -27,7 +27,7 @@ class CurrentUserSerializer(BaseUserSerializer): """ class Meta(BaseUserSerializer.Meta): - fields = ["id", "username", "email", "phone_number", "gender"] + fields = ["id", "username", "email", "phone_number", "gender", "is_librarian"] class BookSerializer(serializers.ModelSerializer): @@ -48,8 +48,9 @@ class BaseBookLoanSerializer(serializers.ModelSerializer): * Issued books have: date_borrowed and date_due. * Returned books have date_returned. """ - book = serializers.PrimaryKeyRelatedField(queryset=Book.objects.exclude(stock=0)) + # book = BookSerializer() + # book_id = serializers.PrimaryKeyRelatedField(queryset=Book.objects.exclude(stock=0), write_only=True) def validate(self, attrs): loan = BookLoan(**attrs) @@ -77,6 +78,20 @@ def validate(self, attrs): return super().validate(attrs) +class ReadBookLoanSerializer(BaseBookLoanSerializer): + book = BookSerializer() + class Meta: + model = BookLoan + fields = [ + "id", + "user", + "book", + "status", + "created_at", + "date_borrowed", + "date_due", + "date_returned", + ] class FullBookLoanSerializer(BaseBookLoanSerializer): """ Book loans serializer for librarians and admin. @@ -133,7 +148,7 @@ class BaseBookRequestSerializer(serializers.ModelSerializer): """ def validate(self, attrs): - if attrs["status"] == "rejected" and not attrs["reason"]: + if attrs.get("status", None) == "rejected" and not attrs.get("reason", None): raise serializers.ValidationError( {"reason": "Reason is required for rejected books."} ) diff --git a/core/signals.py b/core/signals.py index bdef1a1..2f112af 100644 --- a/core/signals.py +++ b/core/signals.py @@ -2,7 +2,7 @@ Signals for Core app """ -from django.db.models.signals import pre_save +from django.db.models.signals import pre_save, post_save from django.dispatch import receiver from django.db.models import F @@ -10,6 +10,8 @@ from .models import Book, BookLoan, BookRequest +import logging +logger = logging.getLogger(__name__) @receiver(pre_save, sender=BookLoan) def update_inventory(sender, **kwargs): @@ -32,7 +34,7 @@ def update_inventory(sender, **kwargs): ) -@receiver(pre_save, sender=BookRequest) +@receiver(post_save, sender=BookRequest) def notify_user(sender, **kwargs): """ A signal that notifies user when their book request is rejected. @@ -41,15 +43,20 @@ def notify_user(sender, **kwargs): if request_instance.id is None: # new object will be created pass else: + loan_previous = BookRequest.objects.get(id=request_instance.id) if loan_previous.status != request_instance.status: # status updated if request_instance.status == "rejected": - message = BaseEmailMessage( - template_name="emails/book_request_rejected.html", - context={ - "name": request_instance.user, - "book": request_instance.book_name, - "reason": request_instance.reason, - }, - ) - message.send([request_instance.user.email]) + try: + message = BaseEmailMessage( + template_name="emails/book_request_rejected.html", + context={ + "name": request_instance.user, + "book": request_instance.book_name, + "reason": request_instance.reason, + }, + ) + message.send([request_instance.user.email]) + logger.info("email sent") + except Exception: + logger.info("email error") diff --git a/core/views.py b/core/views.py index d32a433..5672217 100644 --- a/core/views.py +++ b/core/views.py @@ -14,6 +14,7 @@ from .models import Book, BookLoan, BookRequest from .serializers import ( FullBookLoanSerializer, + ReadBookLoanSerializer, FullBookRequestSerializer, UserBookLoanSerializer, BookSerializer, @@ -50,6 +51,8 @@ def get_queryset(self): def get_serializer_class(self): user = self.request.user + if self.request.method == 'GET': + return ReadBookLoanSerializer if user.is_librarian: return FullBookLoanSerializer return UserBookLoanSerializer diff --git a/lms/settings.py b/lms/settings.py index 69c2f4a..390e8b9 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -162,7 +162,7 @@ # Email settings EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = "localhost" -EMAIL_PORT = 2525 +EMAIL_PORT = 3001 EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_USE_TLS = False From 24b07adfacfd23e8cf998ce11213549eb2383039 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:54:16 +0500 Subject: [PATCH 22/23] add data migration --- ...tatus_alter_bookrequest_status_and_more.py | 105 ++++++++++++++++++ core/models.py | 29 +++-- 2 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 core/migrations/0006_alter_bookloan_status_alter_bookrequest_status_and_more.py diff --git a/core/migrations/0006_alter_bookloan_status_alter_bookrequest_status_and_more.py b/core/migrations/0006_alter_bookloan_status_alter_bookrequest_status_and_more.py new file mode 100644 index 0000000..e6210e9 --- /dev/null +++ b/core/migrations/0006_alter_bookloan_status_alter_bookrequest_status_and_more.py @@ -0,0 +1,105 @@ +# Generated by Django 4.1.5 on 2023-03-03 10:18 + +from django.db import migrations, models + + +def migrate_str_to_int_bookloan(apps, schema_editor): + + MyModel = apps.get_model('core', 'bookloan') + + for mm in MyModel.objects.all(): + status_old = mm.status_old + status_new_int = int(status_old) + mm.status = status_new_int + mm.save() + + +def migrate_str_to_int_bookrequest(apps, schema_editor): + + MyModel = apps.get_model('core', 'bookrequest') + + for mm in MyModel.objects.all(): + status_old = mm.status_old + status_new_int = int(status_old) + mm.status = status_new_int + mm.save() + + +def migrate_str_to_int_user(apps, schema_editor): + + MyModel = apps.get_model('core', 'user') + + for mm in MyModel.objects.all(): + gender_old = mm.gender_old + gender_new_int = int(gender_old) + mm.gender = gender_new_int + mm.save() + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0005_alter_user_gender"), + ] + + operations = [ + migrations.RenameField( + model_name="bookloan", + old_name="status", + new_name="status_old", + ), + migrations.AddField( + model_name="bookloan", + name="status", + field=models.SmallIntegerField( + choices=[ + ("0", "Requested"), + ("1", "Issued"), + ("2", "Rejected"), + ("3", "Returned"), + ], + default="0", + ), + ), + migrations.RunPython(migrate_str_to_int_bookloan), + migrations.RemoveField( + model_name='bookloan', + name='status_old', + ), + migrations.RenameField( + model_name="bookrequest", + old_name="status", + new_name="status_old", + ), + migrations.AddField( + model_name="bookrequest", + name="status", + field=models.SmallIntegerField( + choices=[("0", "Pending"), ("1", "Approved"), ("2", "Rejected")], + default="0", + ), + ), + migrations.RunPython(migrate_str_to_int_bookrequest), + migrations.RemoveField( + model_name='bookrequest', + name='status_old', + ), + migrations.RenameField( + model_name="user", + old_name="gender", + new_name="gender_old", + ), + migrations.AddField( + model_name="user", + name="gender", + field=models.SmallIntegerField( + blank=True, + choices=[("0", "Male"), ("1", "Female"), ("2", "Other")], + null=True, + ), + ), + migrations.RunPython(migrate_str_to_int_user), + migrations.RemoveField( + model_name='user', + name='gender_old', + ), + ] diff --git a/core/models.py b/core/models.py index 03567e4..f1faa78 100644 --- a/core/models.py +++ b/core/models.py @@ -11,13 +11,13 @@ class User(AbstractUser): class UserGender(models.TextChoices): """Enumeration class for user gender""" - MALE = "male", _("Male") - FEMALE = "female", _("Female") - OTHER = "other", _("Other") + MALE = 0, _("Male") + FEMALE = 1, _("Female") + OTHER = 2, _("Other") email = models.EmailField(_("email address"), unique=True, blank=False, null=False) phone_number = models.CharField(max_length=20, blank=True) - gender = models.CharField(max_length=10, choices=UserGender.choices, blank=True, null=True) + gender = models.SmallIntegerField(choices=UserGender.choices, blank=True, null=True) books = models.ManyToManyField("Book", through="BookLoan") @property @@ -55,15 +55,15 @@ class BookLoan(models.Model): class BookLoanStatus(models.TextChoices): """Enumeration class for book loans statues""" - REQUESTED = "requested", _("Requested") - ISSUED = "issued", _("Issued") - REJECTED = "rejected", _("Rejected") - RETURNED = "returned", _("Returned") + REQUESTED = 0, _("Requested") + ISSUED = 1, _("Issued") + REJECTED = 2, _("Rejected") + RETURNED = 3, _("Returned") user = models.ForeignKey(User, on_delete=models.CASCADE) book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='loans') - status = models.CharField( - max_length=10, choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED + status = models.SmallIntegerField( + choices=BookLoanStatus.choices, default=BookLoanStatus.REQUESTED ) created_at = models.DateTimeField(auto_now_add=True) date_borrowed = models.DateField(null=True, blank=True) @@ -80,14 +80,13 @@ class BookRequest(models.Model): class BookRequestStatus(models.TextChoices): """Enumeration class for book request statues""" - PENDING = "pending", _("Pending") - APPROVED = "approved", _("Approved") - REJECTED = "rejected", _("Rejected") + PENDING = (0, "Pending") + APPROVED = (1, "Approved") + REJECTED = (2, "Rejected") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='book_requests') book_name = models.CharField(max_length=100) - status = models.CharField( - max_length=10, + status = models.SmallIntegerField( choices=BookRequestStatus.choices, default=BookRequestStatus.PENDING, ) From af2c9a0ca6d9dd92e3bf82804bbf05729f279f2e Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 6 Mar 2023 14:26:47 +0500 Subject: [PATCH 23/23] change enumeration types in models.py --- core/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/models.py b/core/models.py index f1faa78..3069dc0 100644 --- a/core/models.py +++ b/core/models.py @@ -8,7 +8,7 @@ class User(AbstractUser): """Override base User model""" - class UserGender(models.TextChoices): + class UserGender(models.IntegerChoices): """Enumeration class for user gender""" MALE = 0, _("Male") @@ -52,7 +52,7 @@ def __str__(self): class BookLoan(models.Model): """This model represents user book loans. Loans are managed by librarians and admins.""" - class BookLoanStatus(models.TextChoices): + class BookLoanStatus(models.IntegerChoices): """Enumeration class for book loans statues""" REQUESTED = 0, _("Requested") @@ -77,7 +77,7 @@ def __str__(self): class BookRequest(models.Model): """This model represents unavailable books requested by users""" - class BookRequestStatus(models.TextChoices): + class BookRequestStatus(models.IntegerChoices): """Enumeration class for book request statues""" PENDING = (0, "Pending")