diff --git a/administration/templates/administration/includes/sidebar.html b/administration/templates/administration/includes/sidebar.html index 73e357314f98bcccb5235f59df53fb4a3fdf69ff..76be2e9d24d0e05fd64af810c4cacdeefa3ef04f 100644 --- a/administration/templates/administration/includes/sidebar.html +++ b/administration/templates/administration/includes/sidebar.html @@ -50,4 +50,12 @@ <!-- Divider --> <hr class="sidebar-divider my-0"> + <li class="nav-item"> + <a class="nav-link" href="/administration/news/list"> + <span>Kelola Berita</span></a> + </li> + + <!-- Divider --> + <hr class="sidebar-divider my-0"> + </ul> \ No newline at end of file diff --git a/digipus/settings.py b/digipus/settings.py index f41bb346df16ab08ec55031f2050d334dba53cc3..4b4ac60a8e97b96169a295af78138d14ff28954a 100644 --- a/digipus/settings.py +++ b/digipus/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = [ "register.apps.RegisterConfig", "administration.apps.AdministrationConfig", 'crispy_forms', + "news.apps.NewsConfig", "traffic_statistics", ] diff --git a/digipus/urls.py b/digipus/urls.py index 6f5ec5477ac62cc199a3c424d5a3970ca36507cc..8e694b93f0365307258f1730fa1b2faa5202f188 100644 --- a/digipus/urls.py +++ b/digipus/urls.py @@ -24,5 +24,6 @@ urlpatterns = [ path("", include("authentication.urls"), name="auth"), path("", include("app.urls"), name="app"), path("administration/", include("administration.urls")), + path("", include("news.urls"), name="news"), path("statistics/", include("traffic_statistics.urls")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/news/__init__.py b/news/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/news/admin.py b/news/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e --- /dev/null +++ b/news/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/news/apps.py b/news/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..5a7b92d0f844e1bd89c73e7bba369b07298ae70a --- /dev/null +++ b/news/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NewsConfig(AppConfig): + name = 'news' diff --git a/news/forms.py b/news/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..4269106791db1147ee48589fb5e9e23c3769a894 --- /dev/null +++ b/news/forms.py @@ -0,0 +1,24 @@ +from django import forms +from .models import News + +class NewsForm(forms.ModelForm): + class Meta: + attribute_text_input = { + 'class' : 'form-control col-6' + } + attribute_text_area = { + 'class' : 'form-control col-8', + 'rows' : "5" + } + model = News + fields = [ + "title", "cover", "content" + ] + widgets = { + "title" : forms.TextInput( + attrs=attribute_text_input + ), + "content" : forms.Textarea( + attrs=attribute_text_area + ) + } \ No newline at end of file diff --git a/news/migrations/0001_initial.py b/news/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..7c584641a29f5191a19848e13e52ed48722d8ff4 --- /dev/null +++ b/news/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2020-10-03 07:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='News', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('content', models.TextField()), + ('timestamp', models.DateTimeField(auto_now=True)), + ('cover', models.ImageField(upload_to='news')), + ], + ), + ] diff --git a/news/migrations/__init__.py b/news/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/news/models.py b/news/models.py new file mode 100644 index 0000000000000000000000000000000000000000..76bdd59a66217a31173fa59916c09060c997092c --- /dev/null +++ b/news/models.py @@ -0,0 +1,8 @@ +from django.db import models + +class News(models.Model): + title = models.CharField(max_length = 100) + content = models.TextField() + timestamp = models.DateTimeField(auto_now = True) + cover = models.ImageField(upload_to = 'news') + \ No newline at end of file diff --git a/news/templates/news_form.html b/news/templates/news_form.html new file mode 100644 index 0000000000000000000000000000000000000000..8170ffa0db230be968eab5655860bab64e69f97d --- /dev/null +++ b/news/templates/news_form.html @@ -0,0 +1,70 @@ +{% extends 'administration/base_administrasi2.html' %} + +{% load static %} + +{% block title %} + <title>Form Pembuatan Berita</title> +{% endblock %} + +{% block stylesheets %} + <script src="https://code.jquery.com/jquery-3.4.1.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" + integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" + crossorigin="anonymous"></script> + + <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" + integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"> + <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" + integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" + crossorigin="anonymous"></script> + + <link href="https://cdn.jsdelivr.net/npm/summernote@0.8.16/dist/summernote-bs4.min.css" rel="stylesheet"> + <script src="https://cdn.jsdelivr.net/npm/summernote@0.8.16/dist/summernote-bs4.min.js"></script> +{% endblock stylesheets %} + +{% block content %} + <div class="row-12"> + {% if type == 'update' %} + <form method="POST" action="{% url 'update_news_form' form.instance.pk%}" enctype="multipart/form-data"> + {% csrf_token %} + <h1 class="h3 mb-2 text-gray-800"> + Formulir Sunting Berita + </h1> + <br> + {{form.as_p}} + <br> + <button type="submit" class="btn btn-primary">Update News</button> + </form> + {% else %} + <form method="POST" action="{% url 'post_news_form' %}" enctype="multipart/form-data"> + {% csrf_token %} + <h1 class="h3 mb-2 text-gray-800"> + Formulir Tambah Berita + </h1> + <br> + {{form.as_p}} + <br> + <button type="submit" class="btn btn-primary">Post News</button> + </form> + {% endif %} + </div> + + <script> + $('#id_content').summernote({ + placeholder: 'Write here ...', + tabsize: 2, + height: 300, + codeviewFilter: false, + codeviewIframeFilter: true, + toolbar: [ + ['style', ['style']], + ['font', ['bold', 'italic', 'underline', 'clear']], + ['fontname', ['fontname']], + ['color', ['color']], + ['para', ['ul', 'ol', 'paragraph']], + ['insert', ['link', 'picture', 'video']], + ['view', ['fullscreen']], + ] + }); + </script> +{% endblock %} diff --git a/news/templates/news_list.html b/news/templates/news_list.html new file mode 100644 index 0000000000000000000000000000000000000000..d3c99fdd55fd2a870191ea401dad68c6fb62a870 --- /dev/null +++ b/news/templates/news_list.html @@ -0,0 +1,73 @@ +{% extends 'administration/base_administrasi2.html' %} +{% load static %} + +{% block title %} +<title>Kelola Berita | Digipus</title> +{% endblock %} + +{% block content %} +<!-- Page Heading --> +<h1 class="h3 mb-2 text-gray-800">Kelola Berita</h1> +<br> +<!-- DataTales Example --> +<div class="card shadow mb-4"> + <div class="card-header py-3"> + <div class="d-flex"> + <div class="mr-auto p-2"> + <h6 class="m-0 font-weight-bold text-primary">Tabel Daftar Berita</h6> + </div> + <div class="p-2"> + <a href="/administration/news/form" class="accept-button button-decoration button-header">Buat Berita Baru</a> + </div> + </div> + </div> + <div class="card-body"> + <div class="table-responsive"> + <table class="table table-bordered" id="dataTable"> + <caption class="table_caption">Daftar Berita</caption> + <thead> + <tr> + <th id="table_title">Judul</th> + <th id="table_timestamp">Waktu Diperbarui Terakhir</th> + <th id="table_buttons">Pilihan</th> + </tr> + </thead> + <tbody> + {% for current in news_list %} + <tr> + <td>{{ current.title }}</td> + <td>{{ current.timestamp }}</td> + <td class="verif-buttons"> + <span> + <a href="/administration/news/form/edit/{{ current.id }}" class="accept-button button-decoration">Update</a> + <button type="button" class="reject-button button-decoration" data-toggle="modal" data-target="#confirmModal{{ current.id }}">Hapus</button> + <div class="modal fade" id="confirmModal{{ current.id }}" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="exampleModalLabel">Konfirmasi Penghapusan Berita "{{current.title}}"</h5> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <p>Silahkan konfirmasi penghapusan berita dengan tekan tombol hapus di bawah</p> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button> + <a href="/administration/news/delete/{{current.id}}" type="button" class="btn btn-danger">Hapus</a> + </div> + </div> + </div> + </div> + </span> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> +</div> + +{% endblock %} \ No newline at end of file diff --git a/news/tests.py b/news/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..595532f63a1943dbf19cdf2dd6d626c5fe94f8f1 --- /dev/null +++ b/news/tests.py @@ -0,0 +1,284 @@ +import shutil +import tempfile +import uuid + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings, Client +from django.core.files.uploadedfile import SimpleUploadedFile +from io import BytesIO +from PIL import Image +from django.core.files.base import File + +from .models import News +from administration import models + +MOCK_MEDIA_ROOT = tempfile.mkdtemp() + +@override_settings(MEDIA_ROOT = MOCK_MEDIA_ROOT) +class NewsModelTest(TestCase): + def setUp(self): + self.test_file_jpg1 = SimpleUploadedFile("foto1.jpg", b"file_content") + self.test_file_jpg2 = SimpleUploadedFile("foto2.jpg", b"file_content") + self.news1 = News.objects.create( + title = 'title1', + content = 'content news1', + cover = self.test_file_jpg1 + ) + self.news2 = News.objects.create( + title = 'title2', + content = 'content news2', + cover = self.test_file_jpg2 + ) + + self.news1.save() + self.news2.save() + + @classmethod + def tearDownClass(cls): + shutil.rmtree(MOCK_MEDIA_ROOT, ignore_errors=True) + super().tearDownClass() + + def test_object_news_is_created(self): + self.assertTrue(type(self.news1), News) + self.assertTrue(type(self.news2), News) + + def test_id_news_is_generated(self): + self.assertIsNotNone(self.news1.id) + self.assertIsNotNone(self.news2.id) + + def test_news1_title_is_title1(self): + self.assertEqual(self.news1.title, "title1") + + def test_news1_adn_news2_title_is_not_equal(self): + self.assertNotEqual(self.news1.title, self.news2.title) + + def test_news1_content_is_content_news1(self): + self.assertEqual(self.news1.content, "content news1") + + def test_news1_and_news2_content_is_not_equal(self): + self.assertNotEqual(self.news1.content, self.news2.content) + + def test_news1_timestamp_is_not_none(self): + self.assertIsNotNone(self.news1.timestamp) + + def test_news1_cover_is_not_none(self): + self.assertIsNotNone(self.news1.cover) + + def test_news1_and_news2_cover_is_not_equal(self): + self.assertNotEqual(self.news1.cover, self.news2.cover) + +@override_settings(MEDIA_ROOT = MOCK_MEDIA_ROOT) +class NewsFormTest(TestCase): + def setUp(self): + self.client = Client() + self.root_url = "/" + self.base_url = '/administration/news/' + self.form_url = self.base_url+"form/" + self.post_form_url = self.form_url+"post" + self.delete_news_url = self.base_url+"delete/" + self.update_form_url = self.form_url+"edit/" + self.list_news_url = self.base_url+"list" + self.admin_jpg_name = "news_admin.jpg" + self.admin_credential = { + "email": "admin@gov.id", + "password": str(uuid.uuid4()) + } + self.contributor_credential = { + "email": "kontributor@gov.id", + "password": str(uuid.uuid4()) + } + self.admin = get_user_model().objects.create_user( + **self.admin_credential, name="Admin", is_admin=True) + self.contributor = get_user_model().objects.create_user( + **self.contributor_credential, name="Kontributor", is_contributor=True + ) + self.test_file_jpg3 = SimpleUploadedFile("foto3.jpg", b"file_content") + self.test_file_jpg4 = SimpleUploadedFile("foto4.jpg", b"file_content") + self.news3 = News.objects.create( + title = 'title3', + content = 'content news3', + cover = self.test_file_jpg3 + ) + self.news4 = News.objects.create( + title = 'title4', + content = 'content news4', + cover = self.test_file_jpg4 + ) + self.news3.save() + self.news4.save() + + @classmethod + def tearDownClass(cls): + shutil.rmtree(MOCK_MEDIA_ROOT, ignore_errors=True) + super().tearDownClass() + + @staticmethod + def get_image_file(name, ext='png', size=(50, 50), color=(256, 0, 0)): + file_obj = BytesIO() + image = Image.new("RGBA", size=size, color=color) + image.save(file_obj, ext) + file_obj.seek(0) + return File(file_obj, name=name) + + def test_post_form_news_open_raw_url(self): + self.client.login(**self.admin_credential) + response = self.client.get(self.post_form_url) + self.assertRedirects(response, self.root_url) + self.client.logout() + + def test_post_form_news_by_admin_and_form_is_valid_data_exist(self): + self.client.login(**self.admin_credential) + news_data = { + "title" : "news_admin", + "content" : "content_admin", + "cover" : self.get_image_file(self.admin_jpg_name) + } + response = self.client.post(self.post_form_url, news_data, format='multipart') + self.assertTrue(News.objects.filter(title="news_admin").exists()) + self.assertRedirects(response, self.list_news_url) + self.client.logout() + + def test_post_form_news_by_admin_and_form_is_not_valid_and_data_not_exist(self): + self.client.login(**self.admin_credential) + news_data = { + "title" : "not_news_admin", + } + response = self.client.post(self.post_form_url, news_data, format='multipart') + self.assertFalse(News.objects.filter(title="not_news_admin").exists()) + self.assertRedirects(response, self.root_url) + self.client.logout() + + def test_post_form_news_by_kontributi_and_form_is_valid_data_not_exist(self): + self.client.login(**self.contributor_credential) + news_data = { + "title" : "news_contrib", + "content" : "content_admin", + "cover" : self.get_image_file(self.admin_jpg_name) + } + response = self.client.post(self.post_form_url, news_data, format='multipart') + self.assertFalse(News.objects.filter(title="news_admin").exists()) + self.assertRedirects(response, self.root_url) + self.client.logout() + + def test_delete_news_by_admin_and_data_is_exist_and_data_deleted(self): + self.client.login(**self.admin_credential) + id_news4 = self.news4.id + response = self.client.post(self.delete_news_url+str(id_news4), {}) + self.assertFalse(News.objects.filter(pk=id_news4).exists()) + self.assertRedirects(response, self.list_news_url) + self.client.logout() + + def test_delete_news_by_contributor_and_data_is_exist_and_data_not_deleted(self): + self.client.login(**self.contributor_credential) + id_news4 = self.news4.id + response = self.client.post(self.delete_news_url+str(id_news4), {}) + self.assertTrue(News.objects.filter(pk=id_news4).exists()) + self.assertRedirects(response, self.root_url) + self.client.logout() + + def test_delete_news_by_anonymous_and_data_is_exist_and_data_not_deleted(self): + id_news4 = self.news4.id + response = self.client.post(self.delete_news_url+str(id_news4), {}) + self.assertTrue(News.objects.filter(pk=id_news4).exists()) + self.assertEqual(response.status_code, 302) + + def test_delete_news_by_admin_and_data_is_not_exist(self): + self.client.login(**self.admin_credential) + response = self.client.post(self.delete_news_url+"4124123", {}) + self.assertRedirects(response, self.list_news_url) + self.client.logout() + + def test_form_news_url_is_exist_for_admin(self): + self.client.login(**self.admin_credential) + response = self.client.get(self.form_url) + self.assertEqual(response.status_code, 200) + self.client.logout() + + def test_form_news_url_is_not_exist_for_contributor(self): + self.client.login(**self.contributor_credential) + response = self.client.get(self.form_url) + self.assertRedirects(response, self.root_url) + self.client.logout() + + def test_form_news_url_is_not_exist_for_anonymous(self): + response = self.client.get(self.form_url) + self.assertEqual(response.status_code, 302) + + def test_form_news_using_news_form_html(self): + self.client.login(**self.admin_credential) + response = self.client.get(self.form_url) + self.assertTemplateUsed(response, 'news_form.html') + self.client.logout() + + def test_update_form_news_using_news_form_html(self): + self.client.login(**self.admin_credential) + id_news4 = str(self.news4.id) + response = self.client.get(self.update_form_url+id_news4) + self.assertTemplateUsed(response, 'news_form.html') + self.client.logout() + + def test_update_form_news_but_news_not_exist(self): + self.client.login(**self.admin_credential) + response = self.client.get(self.update_form_url+"283912") + self.assertEqual(response.status_code, 302) + self.client.logout() + + def test_update_news_form_for_non_admin(self): + self.client.login(**self.contributor_credential) + id_news3 = str(self.news3.id) + response = self.client.get(self.update_form_url+id_news3) + self.assertRedirects(response, self.root_url) + self.client.logout() + + def test_update_form_news_by_admin_and_form_is_valid_change_data(self): + self.client.login(**self.admin_credential) + id_news3 = self.news3.id + news_data = { + "id" : id_news3, + "title" : "title3_new", + "content" : "content_admin", + "cover" : self.get_image_file(self.admin_jpg_name) + } + response = self.client.post(self.update_form_url+str(id_news3), news_data, format='multipart') + self.assertTrue(News.objects.filter(title="title3_new").exists()) + self.assertFalse(News.objects.filter(title="title3").exists()) + self.assertEqual(response.status_code, 302) + self.client.logout() + + def test_update_form_news_by_contributor_and_form_is_valid_not_change_data(self): + self.client.login(**self.contributor_credential) + id_news3 = self.news3.id + news_data = { + "id" : id_news3, + "title" : "title3_new", + "content" : "content_admin", + "cover" : self.get_image_file(self.admin_jpg_name) + } + response = self.client.post(self.update_form_url+str(id_news3), news_data, format='multipart') + self.assertFalse(News.objects.filter(title="title3_new").exists()) + self.assertTrue(News.objects.filter(title="title3").exists()) + self.assertEqual(response.status_code, 302) + self.client.logout() + + def test_list_news_url_is_exist_for_admin(self): + self.client.login(**self.admin_credential) + response = self.client.get(self.list_news_url) + self.assertEqual(response.status_code, 200) + self.client.logout() + + def test_list_news_url_is_not_exist_for_contributor(self): + self.client.login(**self.contributor_credential) + response = self.client.get(self.list_news_url) + self.assertRedirects(response, self.root_url) + self.client.logout() + + def test_list_news_url_is_not_exist_for_anonymous(self): + response = self.client.get(self.list_news_url) + self.assertEqual(response.status_code, 302) + + def test_list_news_using_news_form_html(self): + self.client.login(**self.admin_credential) + response = self.client.get(self.list_news_url) + self.assertTemplateUsed(response, 'news_list.html') + self.client.logout() + \ No newline at end of file diff --git a/news/urls.py b/news/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..568d2060336a7583d0fa8ad63ae48e5256d5e4ca --- /dev/null +++ b/news/urls.py @@ -0,0 +1,31 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path( + 'administration/news/form/post', + views.post_news_form, + name = "post_news_form" + ), + path( + 'administration/news/delete/<int:id_news>', + views.delete_news_by_id, + name = "delete_news_by_id" + ), + path( + 'administration/news/form/', + views.show_news_form, + name = "show_news_form" + ), + path( + 'administration/news/form/edit/<int:id_news>', + views.update_news_form, + name = "update_news_form" + ), + path( + 'administration/news/list', + views.show_news_list, + name = "show_news_list" + ), +] \ No newline at end of file diff --git a/news/views.py b/news/views.py new file mode 100644 index 0000000000000000000000000000000000000000..296f774b906429eb9cd3b2902d50d6143ce3537d --- /dev/null +++ b/news/views.py @@ -0,0 +1,90 @@ +from django.http import HttpResponse +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required +from django.views.decorators.csrf import csrf_exempt + +from .forms import NewsForm + +from .models import News + +html_news_form = "news_form.html" +html_news_list = "news_list.html" +login_url = "/login/" +root_url = "/" +news_list_url = "/administration/news/list" + +@login_required(login_url=login_url) +def post_news_form(request): + if request.user.is_admin is False: + return redirect(root_url) + + form = NewsForm(request.POST or None, request.FILES or None) + if form.is_valid(): + news_data = News.objects.create( + title=request.POST["title"], + content=request.POST["content"], + cover=request.FILES.get("cover", False) + ) + news_data.save() + return redirect(news_list_url) + return redirect("/") + +@csrf_exempt +@login_required(login_url=login_url) +def delete_news_by_id(request, id_news): + if request.user.is_admin is False: + return redirect(root_url) + + try: + news_data = News.objects.get(pk=id_news) + except News.DoesNotExist: + return redirect(news_list_url) + news_data.cover.delete() + news_data.delete() + return redirect(news_list_url) + +@login_required(login_url=login_url) +def show_news_form(request): + if request.user.is_admin is False: + return redirect(root_url) + + response = { + "form" : NewsForm() + } + return render(request, html_news_form, response) + +@login_required(login_url=login_url) +def update_news_form(request, id_news): + if request.user.is_admin is False: + return redirect(root_url) + + try: + news_data = News.objects.get(pk=id_news) + except News.DoesNotExist: + return redirect(news_list_url) + + form = NewsForm(instance=news_data) + if request.method == "POST": + form = NewsForm(request.POST or None, + request.FILES or None, + instance=news_data) + if form.is_valid(): + form.save() + return redirect(news_list_url) + + response = { + 'form': form, + 'type': 'update', + 'pk':id_news + } + return render(request, html_news_form, response) + +@login_required(login_url=login_url) +def show_news_list(request): + if request.user.is_admin is False: + return redirect(root_url) + + response = { + "news_list" : News.objects.all() + } + return render(request, html_news_list, response) \ No newline at end of file