From 599343b470869b77cbe74ebbc706427ca234870b Mon Sep 17 00:00:00 2001 From: Anthony Dewa Priyasembada <anthony.dewa@ui.ac.id> Date: Sat, 31 Oct 2020 11:02:12 +0700 Subject: [PATCH] [#82] Reading List --- app/migrations/0027_readlater.py | 29 ++++ app/models.py | 8 + app/services.py | 17 ++- app/templates/app/detail_materi.html | 53 ++++++- .../app/includes/sidebar_profile.html | 5 + app/templates/baca-nanti.html | 84 ++++++++++ app/tests.py | 143 ++++++++++++++++++ app/urls.py | 5 +- app/views.py | 39 ++++- 9 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 app/migrations/0027_readlater.py create mode 100644 app/templates/baca-nanti.html diff --git a/app/migrations/0027_readlater.py b/app/migrations/0027_readlater.py new file mode 100644 index 0000000..4652422 --- /dev/null +++ b/app/migrations/0027_readlater.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1 on 2020-10-30 13:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('app', '0027_auto_20201030_1648'), + ] + + operations = [ + migrations.CreateModel( + name='ReadLater', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('materi', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.materi')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('materi', 'user')}, + }, + ), + ] diff --git a/app/models.py b/app/models.py index e531ad1..d1e0729 100644 --- a/app/models.py +++ b/app/models.py @@ -272,3 +272,11 @@ class LaporanMateri(models.Model): laporan = models.TextField(validators=[MinValueValidator(30), MaxValueValidator(120)], default="") timestamp = models.DateTimeField(default=timezone.now) is_rejected = models.BooleanField(default=False) + +class ReadLater(models.Model): + materi = models.ForeignKey(Materi, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + timestamp = models.DateTimeField(default=timezone.now) + + class Meta: + unique_together = ["materi", "user"] \ No newline at end of file diff --git a/app/services.py b/app/services.py index ab9d563..a7032b8 100644 --- a/app/services.py +++ b/app/services.py @@ -15,7 +15,7 @@ from pydrive.drive import GoogleDrive from administration.models import VerificationReport from app.forms import SuntingProfilForm from app.models import Category, Like, LikeComment, DislikeComment, Materi, Comment, Rating, DownloadStatistics, \ - ViewStatistics + ViewStatistics, ReadLater from app.utils.fileManagementUtil import get_random_filename, remove_image_exifdata from digipus import settings import requests @@ -470,3 +470,18 @@ class GoogleDriveUploadService: file1["title"] = title print("title: %s, mimeType: %s" % (file1["title"], file1["mimeType"])) file1.Upload() + +class ReadLaterService: + + @staticmethod + def toggle_read_later(materi_id, current_user): + materi = get_object_or_404(Materi, pk=materi_id) + read_later_item_exist = ReadLater.objects.filter(materi=materi, user=current_user).exists() + if read_later_item_exist: + read_later_item = get_object_or_404(ReadLater, materi=materi, user=current_user) + read_later_item.delete() + response = {"success": True, "read_later_checked": False} + else: + ReadLater(materi=materi, user=current_user).save() + response = {"success": True, "read_later_checked": True} + return response \ No newline at end of file diff --git a/app/templates/app/detail_materi.html b/app/templates/app/detail_materi.html index 72d758c..c2394eb 100644 --- a/app/templates/app/detail_materi.html +++ b/app/templates/app/detail_materi.html @@ -106,7 +106,7 @@ div.review { <div class="col col-3 cover"> <img src={{materi_data.cover.url}} alt="cover"> </div> - <div class="col col-6 ml-3 book"> + <div class="col col-8 ml-3 book"> <h2>{{materi_data.title}}</h2> <div class="category-wrapper"> {% for category in materi_data.categories.all %} @@ -256,12 +256,32 @@ div.review { </div> </div> </div> + + {% if is_in_read_later_list %} + <button class="btn btn-book shadow-sm p-2 mr-2 bg-primary text-white rounded align-self-center" + type="button" id="readLaterButton" aria-haspopup="true" aria-expanded="false" + onclick="postToggleReadLater()"> + <em class="align-self-center far fa-check-square" id="readLaterText"></em> Baca Nanti + </button> + {% else %} + <button class="btn btn-book shadow-sm p-2 mr-2 bg-white text-primary rounded align-self-center" + type="button" id="readLaterButton" aria-haspopup="true" aria-expanded="false" + onclick="postToggleReadLater()"> + <em class="align-self-center far fa-square" id="readLaterText"></em> Baca Nanti + </button> + {% endif %} {% else %} <button class="btn dropdown-toggle btn-book shadow-sm p-2 mr-2 bg-white rounded align-self-center" type="button" id="dropdownMenuButton" aria-haspopup="true" aria-expanded="false" data-toggle="modal" data-target="#notLoggedInModal"> <em class="align-self-center far fa-star"></em> Beri Rating </button> + + <button class="btn btn-book shadow-sm p-2 mr-2 bg-white text-primary rounded align-self-center" + type="button" id="readLaterButton" aria-haspopup="true" aria-expanded="false" + onclick="postToggleReadLater()"> + <em class="align-self-center far fa-square" id="readLaterText"></em> Baca Nanti + </button> {% endif %} </div> </div> @@ -603,6 +623,37 @@ div.review { }); } + function postToggleReadLater() { + $.ajaxSetup({ + beforeSend: function (xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); + } + } + }); + + $.ajax({ + type: 'POST', + url: "{% url 'toggle-read-later' %}", + data: { + 'materi_id': "{{ materi_data.id }}", + }, + success: changeReadLaterButton, + dataType: 'html' + }); + } + + function changeReadLaterButton(data, jqXHR) { + var data = $.parseJSON(data) + if (data['read_later_checked']) { + $('#readLaterButton').removeClass("bg-white text-primary").addClass("bg-primary text-white") + $('#readLaterText').removeClass("fa-square").addClass("fa-check-square") + } else { + $('#readLaterButton').removeClass("bg-primary text-white").addClass("bg-white text-primary") + $('#readLaterText').removeClass("fa-check-square").addClass("fa-square") + } + } + function LikePost(data, jqXHR) { var data = $.parseJSON(data) var likeCount = parseInt($('.info-content')[6].textContent) diff --git a/app/templates/app/includes/sidebar_profile.html b/app/templates/app/includes/sidebar_profile.html index 69c80bb..d28a4c5 100644 --- a/app/templates/app/includes/sidebar_profile.html +++ b/app/templates/app/includes/sidebar_profile.html @@ -27,4 +27,9 @@ <a class="nav-link" href="/given-rating/"> <span>Rating Diberikan</span></a> </li> + + <li class="nav-item"> + <a class="nav-link" href="/baca-nanti"> + <span>Baca Nanti</span></a> + </li> </ul> \ No newline at end of file diff --git a/app/templates/baca-nanti.html b/app/templates/baca-nanti.html new file mode 100644 index 0000000..9697283 --- /dev/null +++ b/app/templates/baca-nanti.html @@ -0,0 +1,84 @@ +{% extends 'app/base_profile.html' %} +{% load static %} + +{% block title %} + <title>Baca Nanti | Digipus</title> +{% endblock %} +{% block stylesheets %} + <link rel="stylesheet" type="text/css" href="{% static 'app/css/katalog_materi.css' %}"> + <!-- Optional JavaScript --> + <!-- jQuery first, then Popper.js, then Bootstrap JS --> + <script src="https://code.jquery.com/jquery-3.5.1.min.js" + integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" + integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" + crossorigin="anonymous"></script> + <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" + integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" + crossorigin="anonymous"></script> + + <!-- Bootstrap core CSS --> + <link href="../../static/app/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet"> + + <!-- Custom styles for this template --> + <link href="../../static/app/css/heroic-features.css" rel="stylesheet"> + + + <link rel="icon" type="image/png" href="{% static 'images/icons/logo.ico' %}" /> + + + <link rel="stylesheet" type="text/css" href="{% static 'fonts/font-awesome-4.7.0/css/font-awesome.min.css' %}"> + + + <link rel="stylesheet" type="text/css" href="{% static 'vendor/animate/animate.css' %}"> + + <link rel="stylesheet" type="text/css" href="{% static 'vendor/css-hamburgers/hamburgers.min.css' %}"> + + <link rel="stylesheet" type="text/css" href="{% static 'vendor/animsition/css/animsition.min.css' %}"> + + <link rel="stylesheet" type="text/css" href="{% static 'vendor/select2/select2.min.css' %}"> + + <link rel="stylesheet" type="text/css" href="{% static 'vendor/daterangepicker/daterangepicker.css' %}"> + + <link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}"> + <link rel="stylesheet" type="text/css" href="{% static 'css/util.css' %}"> + +{% endblock %} + +{% block content %} + <div class="container"> + <div class="col-20"> + <h1 class="mt-2">Daftar Materi Yang Belum Dibaca</h1> + <hr class="mt-0 mb-4"> + + {% if read_later_list %} + <div class="container row content"> + <div class="col-20 books"> + {% for item in read_later_list %} + <div class="card book"> + <img src={{item.materi.cover.url}} class="card-img-top" alt="cover" + style="height:200px; widows: 200px; overflow: hidden;"></img> + <div class="card-body"> + <h5 class="card-title">{{item.materi.title}}</h5> + <p class="card-text">{{item.materi.author}}</p> + <p class="card-text">Diunggah oleh + <a class="card-link" href="{% url 'katalog-per-kontributor' item.materi.uploader.email %}"> + {{item.materi.uploader.name}} + </a> + </p> + <a href="{% url 'view-materi' item.materi.id %}" class="btn btn-book">Baca</a> + <a href="{% url 'detail-materi' item.materi.id %}" class="btn btn-book">Detail</a> + </div> + </div> + {% endfor %} + </div> + </div> + {% else %} + <h1>Anda Tidak Memiliki Daftar Baca Nanti</h1> + {% endif %} + </div> + </div> + + +{% endblock %} + diff --git a/app/tests.py b/app/tests.py index f69dc13..5b83a94 100644 --- a/app/tests.py +++ b/app/tests.py @@ -42,6 +42,7 @@ from .models import ( ReqMaterial, RatingContributor, ViewStatistics, + ReadLater ) from .services import ( @@ -3444,6 +3445,148 @@ class MateriRecommendationTest(TestCase): list = [int(id) for id in re.findall(r"Materi\s(\d+)[^\d]", response.content.decode())] self.assertEqual(list, [1, 2]) +class BacaNantiTest(TestCase): + def setUp(self): + self.contributor_credential = { + "email": "kontributor@gov.id", + "password": id_generator() + } + self.user_one_credential = { + "email": "user_one@user.id", + "password": id_generator() + } + self.user_two_credential = { + "email": "user_two@user.id", + "password": id_generator() + } + self.contributor = get_user_model().objects.create_user( + **self.contributor_credential, name="Kontributor", is_contributor=True + ) + self.user_one = get_user_model().objects.create_user(**self.user_one_credential, name="User One") + self.user_two = get_user_model().objects.create_user(**self.user_two_credential, name="User Two") + self.cover = SimpleUploadedFile( + "cover.jpg", + b"Test file" + ) + self.content = SimpleUploadedFile( + "content.txt", + b"Test file" + ) + Materi(title="Materi 1", author="Agas", uploader=self.contributor, + publisher="Kelas SC", descriptions="Deskripsi Materi 1", + status="APPROVE", cover=self.cover, content=self.content).save() + Materi(title="Materi Dua", author="Author", uploader=self.contributor, + publisher="Publisher", descriptions="Deskripsi Materi Dua", + status="APPROVE", cover=self.cover, content=self.content).save() + self.materi1 = Materi.objects.filter(title='Materi 1').get() + self.materi2 = Materi.objects.filter(title='Materi Dua').get() + self.url = '/baca-nanti/' + self.toggle_url = '/baca-nanti-toggle/' + self.url_materi = '/materi/{}/'.format(self.materi1.id) + + def test_readlater_object_can_be_created(self): + ReadLater(materi=self.materi1, user=self.user_one).save() + read_later = ReadLater.objects.first() + self.assertEqual(read_later.materi, self.materi1) + self.assertEqual(read_later.user, self.user_one) + + def test_readlater_materi_must_not_unique(self): + ReadLater(materi=self.materi1, user=self.user_one).save() + ReadLater(materi=self.materi1, user=self.user_two).save() + read_later_one = ReadLater.objects.get(user=self.user_one) + read_later_two = ReadLater.objects.get(user=self.user_two) + self.assertEqual(read_later_one.materi, self.materi1) + self.assertEqual(read_later_one.user, self.user_one) + self.assertEqual(read_later_two.materi, self.materi1) + self.assertEqual(read_later_two.user, self.user_two) + + def test_readlater_user_must_not_unique(self): + ReadLater(materi=self.materi1, user=self.user_one).save() + ReadLater(materi=self.materi2, user=self.user_one).save() + read_later_one = ReadLater.objects.get(materi=self.materi1) + read_later_two = ReadLater.objects.get(materi=self.materi2) + self.assertEqual(read_later_one.materi, self.materi1) + self.assertEqual(read_later_one.user, self.user_one) + self.assertEqual(read_later_two.materi, self.materi2) + self.assertEqual(read_later_two.user, self.user_one) + + def test_readlater_materi_combined_with_user_must_be_unique(self): + with self.assertRaises(IntegrityError) as context: + ReadLater(materi=self.materi1, user=self.user_one).save() + ReadLater(materi=self.materi1, user=self.user_one).save() + self.assertTrue('already exists' in str(context.exception)) + + def test_readlater_materi_cant_null(self): + with self.assertRaises(IntegrityError): + ReadLater(user=self.user_one).save() + + def test_readlater_user_cant_null(self): + with self.assertRaises(IntegrityError): + ReadLater(materi=self.materi1).save() + + def test_readlater_profile_page_url_exist(self): + self.client.login(**self.user_one_credential) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_readlater_profile_page_using_template(self): + self.client.login(**self.user_one_credential) + response = self.client.get(self.url) + self.assertTemplateUsed(response=response, template_name="baca-nanti.html") + + def test_toggle_readlater_url_exist(self): + self.client.login(**self.user_one_credential) + response = self.client.post(self.toggle_url, {'materi_id': self.materi1.id}) + self.assertEqual(response.status_code, 200) + + def test_checking_readlater_in_materi_create_object(self): + self.client.login(**self.user_one_credential) + self.client.post(self.toggle_url, {'materi_id': self.materi1.id}) + read_later_exist = ReadLater.objects.filter(materi=self.materi1, user=self.user_one).exists() + self.assertEqual(read_later_exist, True) + + def test_unchecking_readlater_in_materi_delete_object(self): + self.client.login(**self.user_one_credential) + self.client.post(self.toggle_url, {'materi_id': self.materi1.id}) + sleep(1) + self.client.post(self.toggle_url, {'materi_id': self.materi1.id}) + read_later_exist = ReadLater.objects.filter(materi=self.materi1, user=self.user_one).exists() + self.assertEqual(read_later_exist, False) + + def test_checking_readlater_in_materi_with_complete_paramater_return_success(self): + self.client.login(**self.user_one_credential) + response = self.client.post(self.toggle_url, {'materi_id': self.materi1.id}) + self.assertJSONEqual( + str(response.content, encoding='utf-8'), + {"success": True, "read_later_checked": True} + ) + + def test_unchecking_readlater_in_materi_with_complete_paramater_return_success(self): + self.client.login(**self.user_one_credential) + self.client.post(self.toggle_url, {'materi_id': self.materi1.id}) + sleep(1) + response = self.client.post(self.toggle_url, {'materi_id': self.materi1.id}) + self.assertJSONEqual( + str(response.content, encoding='utf-8'), + {"success": True, "read_later_checked": False} + ) + + def test_toggle_readlater_return_if_method_snot_post(self): + self.client.login(**self.user_one_credential) + response = self.client.get(self.toggle_url, {'materi_id': self.materi1.id}) + self.assertJSONEqual( + str(response.content, encoding='utf-8'), + {"success": False, "msg": "Unsuported method"} + ) + + def test_toggle_readlater_return_if_paramater_materi_id_not_found(self): + self.client.login(**self.user_one_credential) + response = self.client.post(self.toggle_url) + self.assertJSONEqual( + str(response.content, encoding='utf-8'), + {"success": False, "msg": "Missing parameter"} + ) + class MateriStatsTest(TestCase): def setUp(self): diff --git a/app/urls.py b/app/urls.py index 3e0df23..4ee198a 100644 --- a/app/urls.py +++ b/app/urls.py @@ -5,7 +5,8 @@ from app import views from app.views import (DashboardKontributorView, ProfilView, StatisticsView, SuksesLoginAdminView, SuksesLoginKontributorView, DownloadHistoryView, SuntingProfilView, UploadMateriHTML, UploadMateriView, UploadMateriExcelView, PostsView, - ReqMateriView, KatalogPerKontributorView, MateriFavorite, PasswordChangeViews, password_success, SubmitVisitorView) + ReqMateriView, KatalogPerKontributorView, MateriFavorite, PasswordChangeViews, password_success, + SubmitVisitorView, ReadLaterView) urlpatterns = [ @@ -41,5 +42,7 @@ urlpatterns = [ path("password_success/", views.password_success, name="password_success"), path("given-rating/", views.see_given_rating, name="see_given_rating"), path("submit-visitor/", SubmitVisitorView.as_view(), name="submit-visitor"), + path("baca-nanti/", ReadLaterView.as_view(), name="read-later"), + path("baca-nanti-toggle/", views.toggle_readlater, name="toggle-read-later"), path("stats/", StatisticsView.as_view(), name="stats"), ] diff --git a/app/views.py b/app/views.py index d0dd5bb..5b2b35a 100644 --- a/app/views.py +++ b/app/views.py @@ -31,12 +31,13 @@ from app.models import ( Materi, ReqMaterial, Rating, RatingContributor, - SubmitVisitor + SubmitVisitor, + ReadLater ) from authentication.models import User from .services import DafterKatalogService, DetailMateriService, LikeDislikeService, MateriFieldValidationHelperService, \ DownloadViewMateriHelperService, UploadMateriService, EditProfileService, RevisiMateriService, \ - DownloadHistoryService, GoogleDriveUploadService + DownloadHistoryService, GoogleDriveUploadService, ReadLaterService def permission_denied(request, exception, template_name="error_403.html"): @@ -154,6 +155,12 @@ class DetailMateri(TemplateView): if materi_rating is not None: context['materi_rating_score'] = materi_rating.score + materi_read_later = ReadLater.objects.filter(materi=materi, user=self.request.user).first() + if materi_read_later is not None: + context['is_in_read_later_list'] = True + else: + context['is_in_read_later_list'] = False + context['is_authenticated'] = self.request.user.is_authenticated return context @@ -781,6 +788,34 @@ class SubmitVisitorView(TemplateView): SubmitVisitor(msg=title, user_id=user_id, email=email).save() return JsonResponse({"success": True, "msg": "Buku tamu berhasil ditambahkan"}) +class ReadLaterView(TemplateView): + template_name = 'baca-nanti.html' + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + raise PermissionDenied(request) + return super(ReadLaterView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(ReadLaterView, self).get_context_data(**kwargs) + return context + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + user = self.request.user + context["read_later_list"] = ReadLater.objects.filter(user=user).order_by('-timestamp') + return self.render_to_response(context) + +def toggle_readlater(request): + if request.method == "POST": + materi_id = request.POST.get("materi_id", None) + if materi_id is None: + return JsonResponse({"success": False, "msg": "Missing parameter"}) + + return JsonResponse(ReadLaterService.toggle_read_later(materi_id, request.user)) + else: + return JsonResponse({"success": False, "msg": "Unsuported method"}) + class StatisticsView(TemplateView): template_name = "statistik.html" -- GitLab