diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3e0199a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python +python: + - "2.7" +services: + - sqlite3 +install: +- pip install -q Django==1.11.15 +before_script: + - python manage.py migrate +script: + - python manage.py test list_posts + diff --git a/README.md b/README.md index 35c11e6..f698de8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ -# ListPostsByRating -App developed in Python, using the Django library +[![Build Status](https://travis-ci.com/Ivopires/ListPostsByRating.svg?branch=challenge)](https://travis-ci.com/Ivopires/ListPostsByRating) + +# List Posts By Rating +App developed in Python(2.7), using the Django(1.11.15) framework. + +## How to run + +### Setup Django (version 1.11.15): + 1. Install pip (if you have not installed already): [Guide](https://packaging.python.org/tutorials/installing-packages/) + 2. Install Django (version 1.11.15): + ``` + pip install Django==1.11.15 + ``` + +### Load db scheme +``` +python manage.py migrate +``` + +### Populate db with Posts + +``` +python manage.py shell + +>>>from list_posts.models import Posts +>>>post = Post(post_text='Example text') +>>>post.save() +``` + +### Run tests + +``` +python manage.py test list_posts +``` + +### Run the project +To run project you must follow the following steps: + + 1. Start the server: + + ``` + python manage.py runserver + ``` + + 2. Go to the following link - [127.0.0.1:8000](http://127.0.0.1:8000) + +There are four available endpoints: +``` +/ +/upvote/:post_id +/downvote/:post_id +/posts/ +``` + +The first endpoint is responsible to list the latest 10 posts, ordered by posting date, if by accident 'future' posts are added to the database, these will not show on the page. The second and third endpoints are responsible to give the up/down votes, respectively, to the post with the . Finally, the last endpoint, will show all the available posts, ordered by 'score', being the top-scored posts on the top and the less-scored posts on the bottom. + +To achieve a post score that would allow to order different posts with the same up/down votes ratio, as mentioned on the challenge's README, a Wilson-score Interval metric was used, which value depends solely on the number of up/down votes, total number of votes of the post and on the __z__ value used by the normal distribution (which depends on the degree of confidence used). diff --git a/challenge/__init__.py b/challenge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/challenge/settings.py b/challenge/settings.py new file mode 100644 index 0000000..76e564a --- /dev/null +++ b/challenge/settings.py @@ -0,0 +1,119 @@ +""" +Django settings for challenge project. + +Generated by 'django-admin startproject' using Django 1.11.15. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'hj&%tr8llp&+n*nrq_c!8p7$#f=ocd3w86iq=eh88@kv0-^1=r' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + 'list_posts.apps.ListPostsConfig', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +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', +] + +ROOT_URLCONF = 'challenge.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', + ], + }, + }, +] + +WSGI_APPLICATION = 'challenge.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': + 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': + 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': + 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': + 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/challenge/urls.py b/challenge/urls.py new file mode 100644 index 0000000..7e81be3 --- /dev/null +++ b/challenge/urls.py @@ -0,0 +1,22 @@ +"""challenge URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.11/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url, include +from django.contrib import admin + +urlpatterns = [ + url(r'^', include('list_posts.urls')), + url(r'^admin/', admin.site.urls), +] diff --git a/challenge/wsgi.py b/challenge/wsgi.py new file mode 100644 index 0000000..41e4e35 --- /dev/null +++ b/challenge/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for challenge project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "challenge.settings") + +application = get_wsgi_application() diff --git a/list_posts/__init__.py b/list_posts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/list_posts/admin.py b/list_posts/admin.py new file mode 100644 index 0000000..13be29d --- /dev/null +++ b/list_posts/admin.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib import admin + +# Register your models here. diff --git a/list_posts/apps.py b/list_posts/apps.py new file mode 100644 index 0000000..e32a0fd --- /dev/null +++ b/list_posts/apps.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class ListPostsConfig(AppConfig): + name = 'list_posts' diff --git a/list_posts/migrations/0001_initial.py b/list_posts/migrations/0001_initial.py new file mode 100644 index 0000000..e51624f --- /dev/null +++ b/list_posts/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-09-17 22:48 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('post_text', models.CharField(max_length=500, verbose_name='Post content')), + ('up_votes', models.PositiveIntegerField(default=0, verbose_name='Up votes')), + ('down_votes', models.PositiveIntegerField(default=0, verbose_name='Down votes')), + ], + ), + ] diff --git a/list_posts/migrations/0002_post_pub_date.py b/list_posts/migrations/0002_post_pub_date.py new file mode 100644 index 0000000..e1981b4 --- /dev/null +++ b/list_posts/migrations/0002_post_pub_date.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-09-18 14:05 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('list_posts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='pub_date', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date Published'), + preserve_default=False, + ), + ] diff --git a/list_posts/migrations/0003_post_score.py b/list_posts/migrations/0003_post_score.py new file mode 100644 index 0000000..4614093 --- /dev/null +++ b/list_posts/migrations/0003_post_score.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-09-18 19:51 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('list_posts', '0002_post_pub_date'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='score', + field=models.FloatField(default=0, verbose_name='Post Score'), + ), + ] diff --git a/list_posts/migrations/__init__.py b/list_posts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/list_posts/models.py b/list_posts/models.py new file mode 100644 index 0000000..bbce9ff --- /dev/null +++ b/list_posts/models.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import numpy as np +from django.utils import timezone +from math import sqrt +from django.db import models +from django.utils.encoding import python_2_unicode_compatible + + +@python_2_unicode_compatible +class Post(models.Model): + pub_date = models.DateTimeField('Date Published', default=timezone.now) + post_text = models.CharField('Post content', max_length=500) + up_votes = models.PositiveIntegerField('Up votes', default=0) + down_votes = models.PositiveIntegerField('Down votes', default=0) + score = models.FloatField('Post Score', default=0) + + def __str__(self): + return self.post_text + + def compute_score(self): + """ + The compute_score function will compute the Wilson-score Interval, + which calculation depends on the number of up/down votes and the total + number of votes. + Where: + - p_hat - is the fraction of up votes out of total votes + - total_votes - is the total number of votes + - z - is the normal distribution, which, in order to have 95% + of confidence, has to be equal to 1.96 + """ + + total_votes = self.up_votes + self.down_votes + if self.up_votes == 0: + return 0 + else: + p_hat = np.float64(self.up_votes) / total_votes + z = np.float64(1.96) + + lower_bound = (((p_hat + z * z / (2 * total_votes)) - z * sqrt( + (p_hat * (1 - p_hat) + z * z / + (4 * total_votes)) / total_votes)) / (1 + z * z / total_votes)) + + return lower_bound diff --git a/list_posts/static/list_posts/style.css b/list_posts/static/list_posts/style.css new file mode 100644 index 0000000..4a1f590 --- /dev/null +++ b/list_posts/static/list_posts/style.css @@ -0,0 +1,3 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} diff --git a/list_posts/templates/list_posts/index.html b/list_posts/templates/list_posts/index.html new file mode 100644 index 0000000..362212a --- /dev/null +++ b/list_posts/templates/list_posts/index.html @@ -0,0 +1,15 @@ +{% load static %} + + + +{% if latest_posts_list %} + +{% else %} +

No posts are available.

+{% endif %} diff --git a/list_posts/templates/list_posts/post.html b/list_posts/templates/list_posts/post.html new file mode 100644 index 0000000..8e6c153 --- /dev/null +++ b/list_posts/templates/list_posts/post.html @@ -0,0 +1,26 @@ +{% load static %} + + + +

Post with id {{post.id}}

+ +

{{post.post_text}}

+ +

Results: {{post.up_votes}} Up vote{{post.up_votes|pluralize}} -- + {{post.down_votes}} Down vote{{post.down_votes|pluralize}} with a score of {{post.score}}

+ +{% if error_message %}

{{error_message}}

{% endif %} + +
+ {% csrf_token %} + + +
+
+ + +
+
+ +
+Go home? diff --git a/list_posts/templates/list_posts/results.html b/list_posts/templates/list_posts/results.html new file mode 100644 index 0000000..17c9f1f --- /dev/null +++ b/list_posts/templates/list_posts/results.html @@ -0,0 +1,10 @@ +{% load static %} + + + +

{{ post.post_text }}

+ +

Results: {{post.up_votes}} Up vote{{post.up_votes|pluralize}} -- + {{post.down_votes}} Down vote{{post.down_votes|pluralize}} with a score of {{post.score}}

+ +Go home? diff --git a/list_posts/tests/__init__.py b/list_posts/tests/__init__.py new file mode 100644 index 0000000..ab0a59b --- /dev/null +++ b/list_posts/tests/__init__.py @@ -0,0 +1,9 @@ +import pkgutil +import unittest + +for loader, module_name, is_pkg in pkgutil.walk_packages(__path__): + module = loader.find_module(module_name).load_module(module_name) + for name in dir(module): + obj = getattr(module, name) + if isinstance(obj, type) and issubclass(obj, unittest.case.TestCase): + exec ('%s = obj' % obj.__name__) diff --git a/list_posts/tests/test_model.py b/list_posts/tests/test_model.py new file mode 100644 index 0000000..151822a --- /dev/null +++ b/list_posts/tests/test_model.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.test import TestCase +from list_posts.models import Post +from django.utils import timezone +from django.urls import reverse + + +class PostModelTests(TestCase): + + def test_score_with_no_upvotes(self): + """ + The score property of the Post model should be equal to 0 when there + is any upvote + """ + post = Post( + post_text='Post with no upvotes.', up_votes=0, down_votes=10) + score = post.compute_score() + self.assertEqual(score, 0) + + def test_score_with_no_downvotes(self): + """ + The score property of the Post model should be different to 0 when + there is any downvote + """ + post = Post(post_text='Post with no down.', up_votes=10, down_votes=0) + score = post.compute_score() + self.assertNotEqual(score, 0) + + def test_score_from_posts_with_same_ratio(self): + """ + The score property of the Post model should be greater on a Post with + 1000/1000 up/down votes than on a Post with 100/100 up/down votes, + despite of having the same ratio of up/down votes. + """ + post_100 = Post( + post_text='Post with 100/100 votes', up_votes=100, down_votes=100) + score_100 = post_100.compute_score() + + post_1000 = Post( + post_text='Post with 1000/1000 votes', + up_votes=1000, + down_votes=1000) + score_1000 = post_1000.compute_score() + + self.assertGreater(score_1000, score_100) + + def test_score_from_posts_with_different_scores(self): + """ + The score property of the Post model should be greater on a Post with + 1000/100 up/down votes than on a Post with 10/100 up/down votes. + """ + post_1000 = Post( + post_text='Post with 1000/100 votes', up_votes=1000, down_votes=100) + score_1000 = post_1000.compute_score() + + post_10 = Post( + post_text='Post with 10/100 votes', up_votes=10, down_votes=100) + score_10 = post_10.compute_score() + + self.assertGreater(score_1000, score_10) + + def test_score_up_down_votes_when_post_is_created(self): + """ + All the three properties (up/down votes and score) of the Post model + should be equal to 0 on a Post that was recently created, and when there + wasn't specified a different number of up/down votes than the default + value (i.e., 0) + """ + post_without_votes = Post(post_text='Post without votes.') + + self.assertEqual(post_without_votes.up_votes, 0) + self.assertEqual(post_without_votes.down_votes, 0) + self.assertEqual(post_without_votes.score, 0) diff --git a/list_posts/tests/test_view.py b/list_posts/tests/test_view.py new file mode 100644 index 0000000..b467ee9 --- /dev/null +++ b/list_posts/tests/test_view.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import datetime +from django.test import TestCase +from list_posts.models import Post +from django.utils import timezone +from django.urls import reverse + + +def create_post(post_text, days, up_votes=0, down_votes=0): + """ + Create a post with the given 'post_text' and published with the given + number of up_votes and down_votes, and published the given + number of 'days' offset to now (negative for posts published in the past + and positive for posts that have yet to be published). + """ + date = timezone.now() + datetime.timedelta(days=days) + post = Post.objects.create( + pub_date=date, + post_text=post_text, + up_votes=up_votes, + down_votes=down_votes) + return post + + +class PostIndexViewTest(TestCase): + + def test_no_posts(self): + """ + If no posts exist, an appropriate message is displayed. + """ + response = self.client.get(reverse('list_posts:index')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'No posts are available.') + self.assertQuerysetEqual(response.context['latest_posts_list'], []) + + def test_past_post(self): + """ + Posts with a pub_date in the past are displayed on the index page. + """ + create_post(post_text='Past post.', days=-30) + response = self.client.get(reverse('list_posts:index')) + self.assertQuerysetEqual(response.context['latest_posts_list'], + ['']) + + def test_future_post(self): + """ + Posts with a pub_date in the future aren't displayed on the index + page. + """ + create_post(post_text='Future post.', days=30) + response = self.client.get(reverse('list_posts:index')) + self.assertContains(response, 'No posts are available.') + self.assertQuerysetEqual(response.context['latest_posts_list'], []) + + def test_future_post_and_past_post(self): + """ + Even if both, past and future, posts exist, only past posts are + displayed. + """ + create_post(post_text='Past post.', days=-30) + create_post(post_text='Future post.', days=30) + response = self.client.get(reverse('list_posts:index')) + self.assertQuerysetEqual(response.context['latest_posts_list'], + ['']) + + def test_two_past_posts(self): + """ + The posts index page may display multiple questions. + """ + create_post(post_text='Past post 2.', days=-5) + create_post(post_text='Past post 1.', days=-30) + response = self.client.get(reverse('list_posts:index')) + self.assertQuerysetEqual( + response.context['latest_posts_list'], + ['', '']) + + +class PostDetailViewTest(TestCase): + + def test_future_post(self): + """ + The detail view of a post with a pub_date in the future returns a + 404 not found + """ + future_post = create_post(post_text='Future post.', days=5) + url = reverse('list_posts:post', args=(future_post.id,)) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_past_post(self): + """ + The detail view of a post with a pub_date in the past displays the + post's text. + """ + past_post = create_post(post_text='Past post.', days=-5) + url = reverse('list_posts:post', args=(past_post.id,)) + response = self.client.get(url) + self.assertContains(response, past_post.post_text) diff --git a/list_posts/urls.py b/list_posts/urls.py new file mode 100644 index 0000000..dbfeb6b --- /dev/null +++ b/list_posts/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url +from . import views + +app_name = 'list_posts' +urlpatterns = [ + url(r'^$', views.index, name='index'), + url(r'^(?P[0-9]+)/$', views.PostDetailView.as_view(), name='post'), + url(r'^vote/(?P[0-9]+)/$', views.vote, name='vote'), + url(r'^upvote/(?P[0-9]+)/$', views.up_vote, name='up_vote'), + url(r'^downvote/(?P[0-9]+)/$', views.down_vote, name='down_vote'), + url(r'^posts/', views.list_posts, name='list_posts'), +] diff --git a/list_posts/views.py b/list_posts/views.py new file mode 100644 index 0000000..4be8243 --- /dev/null +++ b/list_posts/views.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.shortcuts import render +from django.http import HttpResponse, Http404, HttpResponseRedirect +from django.urls import reverse +from django.template import loader +from django.views import generic +from django.shortcuts import get_object_or_404, render +from django.utils import timezone +from list_posts.models import Post + + +class PostDetailView(generic.DetailView): + model = Post + template_name = 'list_posts/post.html' + + def get_queryset(self): + """ + Excludes any posts that aren't published yet. + """ + return Post.objects.filter(pub_date__lte=timezone.now()) + + +def index(request): + latest_posts_list = Post.objects.filter( + pub_date__lte=timezone.now()).order_by('-pub_date') + context = {'latest_posts_list': latest_posts_list} + return render(request, 'list_posts/index.html', context=context) + + +def vote(request, post_id): + post = get_object_or_404(Post, pk=post_id) + + try: + vote_type = request.POST['vote'] + except (KeyError, Post.DoesNotExist): + # Redisplay the question voting form. + return render(request, 'list_posts/post.html', { + 'post': post, + 'error_message': "You didn't voted.", + }) + else: + if vote_type == 'upvote': + post.up_votes += 1 + elif vote_type == 'downvote': + post.down_votes += 1 + post.score = post.compute_score() + post.save() + return HttpResponseRedirect(reverse('list_posts:post', args=(post_id,))) + + +def list_posts(request): + ordered_posts_list = Post.objects.order_by('-score') + context = {'latest_posts_list': ordered_posts_list} + return render(request, 'list_posts/index.html', context=context) + + +def up_vote(request, post_id): + post = get_object_or_404(Post, pk=post_id) + post.up_votes += 1 + post.score = post.compute_score() + post.save() + + return render(request, 'list_posts/results.html', {'post': post}) + + +def down_vote(request, post_id): + post = get_object_or_404(Post, pk=post_id) + post.down_votes += 1 + post.score = post.compute_score() + post.save() + + return render(request, 'list_posts/results.html', {'post': post}) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..81d3205 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "challenge.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv)