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/.gitignore b/.gitignore index 7373ddc..f2a8084 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,8 @@ GitHub.sublime-settings !.vscode/launch.json !.vscode/extensions.json .history + +uploads/ +files/ +logs/ +sandbox.py 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/.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/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/__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..4af680a --- /dev/null +++ b/core/admin.py @@ -0,0 +1,21 @@ +"""core admin panel""" + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from core.models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """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 new file mode 100644 index 0000000..599b429 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,14 @@ +"""Core config of LMS""" + +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + """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 new file mode 100644 index 0000000..9628ad7 --- /dev/null +++ b/core/cron.py @@ -0,0 +1,28 @@ +""" 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() + loan_queryset = BookLoan.objects.select_related('book', 'user').filter(date_due__lt=today) + + for loan in loan_queryset: + message = BaseEmailMessage( + template_name="emails/overdue_books.html", + context={ + "name": loan.user, + "book": loan.book, + }, + ) + message.send([loan.user.email]) + logger.info("email sent to %s for %s due: %s", loan.user.email, loan.book, loan.date_due) 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 new file mode 100644 index 0000000..f9f6a9c --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,250 @@ +# Generated by Django 4.1.5 on 2023-02-28 09:47 + +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" + ), + ), + ( + "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" + ), + ), + ( + "email", + models.EmailField( + max_length=254, unique=True, verbose_name="email address" + ), + ), + ("phone_number", models.CharField(blank=True, max_length=20)), + ( + "gender", + models.SmallIntegerField( + blank=True, + choices=[("0", "Male"), ("1", "Female"), ("2", "Other")], + null=True, + ), + ), + ], + 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.URLField()), + ("author", models.CharField(max_length=100)), + ("publisher", models.CharField(max_length=100)), + ("stock", models.IntegerField()), + ], + ), + 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.SmallIntegerField( + choices=[ + ("0", "Pending"), + ("1", "Approved"), + ("2", "Rejected"), + ], + default="0", + ), + ), + ("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, + related_name="book_requests", + 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.SmallIntegerField( + choices=[ + ("0", "Requested"), + ("1", "Issued"), + ("2", "Rejected"), + ("3", "Returned"), + ], + default="0", + ), + ), + ("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, + related_name="loans", + 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(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/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_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/0005_alter_user_gender.py b/core/migrations/0005_alter_user_gender.py new file mode 100644 index 0000000..8091da6 --- /dev/null +++ b/core/migrations/0005_alter_user_gender.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.5 on 2023-02-28 10:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0004_alter_user_gender"), + ] + + operations = [ + 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/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/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..3069dc0 --- /dev/null +++ b/core/models.py @@ -0,0 +1,97 @@ +"""Core model schemas""" + +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.utils.translation import gettext_lazy as _ + + +class User(AbstractUser): + """Override base User model""" + + class UserGender(models.IntegerChoices): + """Enumeration class for user gender""" + + 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.SmallIntegerField(choices=UserGender.choices, blank=True, null=True) + books = models.ManyToManyField("Book", through="BookLoan") + + @property + def is_librarian(self): + """checks is the user is librarian or not + + Returns: + Bool: if the user is librarian. + """ + return self.groups.filter(name='librarian').exists() + + + +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) + publisher = models.CharField(max_length=100) + stock = models.IntegerField() + + def __str__(self): + return str(self.name) + + + +class BookLoan(models.Model): + """This model represents user book loans. Loans are managed by librarians and admins.""" + + class BookLoanStatus(models.IntegerChoices): + """Enumeration class for book loans statues""" + + 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.SmallIntegerField( + 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): + """This model represents unavailable books requested by users""" + + class BookRequestStatus(models.IntegerChoices): + """Enumeration class for book request statues""" + + 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.SmallIntegerField( + 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/permissions.py b/core/permissions.py new file mode 100644 index 0000000..d3a5371 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,32 @@ +""" +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 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 new file mode 100644 index 0000000..c69dc90 --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,198 @@ +""" +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", "is_librarian"] + + +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.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) + 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.") + + 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."} + ) + + 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 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. + """ + + class Meta: + model = BookLoan + fields = [ + "id", + "user", + "book", + "status", + "created_at", + "date_borrowed", + "date_due", + "date_returned", + ] + read_only_fields = ("user", "created_at") + + +class UserBookLoanSerializer(BaseBookLoanSerializer): + """ + Book Loan serializer for users. + """ + + 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", + ) + + +class BaseBookRequestSerializer(serializers.ModelSerializer): + """ + 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. + + """ + + def validate(self, attrs): + if attrs.get("status", None) == "rejected" and not attrs.get("reason", None): + 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 = [ + "id", + "user", + "book_name", + "status", + "created_at", + "reason", + ] + read_only_fields = ("id", "user", "created_at") + + +class UserBookRequestSerializer(BaseBookRequestSerializer): + """ + Book request serializer for Users. + """ + + 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/signals.py b/core/signals.py new file mode 100644 index 0000000..2f112af --- /dev/null +++ b/core/signals.py @@ -0,0 +1,62 @@ +""" +Signals for Core app +""" + +from django.db.models.signals import pre_save, post_save +from django.dispatch import receiver +from django.db.models import F + +from templated_mail.mail import BaseEmailMessage + +from .models import Book, BookLoan, BookRequest + +import logging +logger = logging.getLogger(__name__) + +@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 + 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 + ) + + +@receiver(post_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 + else: + + loan_previous = BookRequest.objects.get(id=request_instance.id) + if loan_previous.status != request_instance.status: # status updated + if request_instance.status == "rejected": + 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/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/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/tests.py b/core/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..edbcdef --- /dev/null +++ b/core/urls.py @@ -0,0 +1,15 @@ +""" +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") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..5672217 --- /dev/null +++ b/core/views.py @@ -0,0 +1,111 @@ +""" +Views for core app +""" + +from rest_framework.filters import SearchFilter +from rest_framework import viewsets +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 +from .models import Book, BookLoan, BookRequest +from .serializers import ( + FullBookLoanSerializer, + ReadBookLoanSerializer, + FullBookRequestSerializer, + UserBookLoanSerializer, + BookSerializer, + UserBookRequestSerializer, +) + + +class BookViewSet(viewsets.ModelViewSet): + """ + Book viewset + """ + + queryset = Book.objects.all() + serializer_class = BookSerializer + filter_backends = [SearchFilter] + search_fields = ["name", "author", "publisher"] + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] + + +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"] + + def get_queryset(self): + user = self.request.user + 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): + user = self.request.user + if self.request.method == 'GET': + return ReadBookLoanSerializer + 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): + """ + 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", + context={ + "name": loan.user, + "book": loan.book, + }, + ) + message.send([loan.user.email]) + return Response({"detail": "reminder email sent to user"}) + + +class BookRequestViewSet(viewsets.ModelViewSet): + """ + Book request model viewset for requesting unavailable books. + """ + + 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) diff --git a/lms/settings.py b/lms/settings.py index 804202b..390e8b9 100644 --- a/lms/settings.py +++ b/lms/settings.py @@ -9,7 +9,8 @@ For the full list of settings and their values, see 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,44 +36,50 @@ # 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_crontab", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "django_filters", + "djoser", + "corsheaders", + "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", + "corsheaders.middleware.CorsMiddleware", + "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 @@ -81,11 +88,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"), } } @@ -95,16 +102,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", }, ] @@ -112,9 +119,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 @@ -124,9 +131,76 @@ # 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" + +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"], + "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"], +} + +SIMPLE_JWT = { + "AUTH_HEADER_TYPES": ("JWT",), + "ACCESS_TOKEN_LIFETIME": timedelta(days=1), +} + + +# Email settings +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = "localhost" +EMAIL_PORT = 3001 +EMAIL_HOST_USER = env("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") +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/email_overdue_books.log" + " 2>&1 "), + ) +] + +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 + } + } +} diff --git a/lms/urls.py b/lms/urls.py index f03d04e..3e0b980 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -14,8 +14,15 @@ 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 + +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 1b08194..fb2635d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,11 @@ Django==4.1.5 +django-filter==3.6.0 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 +django-crontab==0.7.1 +django-cors-headers==3.13. +django-templated-mail==1.1.1