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