├── mysite ├── blog │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_settings.py │ │ ├── test_forms.py │ │ ├── test_templatetags.py │ │ ├── test_models.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_auto_20160216_1442.py │ │ ├── 0004_post_tags.py │ │ ├── 0003_comment.py │ │ └── 0001_initial.py │ ├── templatetags │ │ ├── __init__.py │ │ └── blog_tags.py │ ├── apps.py │ ├── templates │ │ ├── blog │ │ │ ├── post │ │ │ │ ├── latest_posts.html │ │ │ │ ├── share.html │ │ │ │ ├── list.html │ │ │ │ └── detail.html │ │ │ └── base.html │ │ └── pagination.html │ ├── forms.py │ ├── static │ │ └── css │ │ │ └── blog.css │ ├── urls.py │ ├── admin.py │ ├── factories.py │ ├── models.py │ └── views.py ├── mysite │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ └── settings.py ├── functional_tests │ ├── __init__.py │ ├── test_post_share.py │ ├── test_post_detail.py │ ├── test_post_list.py │ └── test_admin.py └── manage.py ├── requirements.in ├── pylintrc ├── requirements.txt ├── .travis.yml ├── .gitignore └── README.md /mysite/blog/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mysite/mysite/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mysite/blog/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mysite/blog/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mysite/functional_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mysite/blog/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """Module docstring""" 2 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | Django 2 | django-taggit 3 | pytz 4 | pytest 5 | factory_boy 6 | pylint 7 | selenium 8 | -------------------------------------------------------------------------------- /mysite/blog/apps.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class BlogConfig(AppConfig): 7 | name = 'blog' 8 | -------------------------------------------------------------------------------- /mysite/blog/templates/blog/post/latest_posts.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mysite/mysite/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | url(r'^admin/', include(admin.site.urls)), 6 | url(r'^blog/', include('blog.urls', namespace='blog', app_name='blog')), 7 | ] 8 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=locally-disabled 3 | 4 | [REPORTS] 5 | # Simplify pylint reports 6 | reports=no 7 | 8 | [FORMAT] 9 | # Maximum number of characters on a single line. 10 | max-line-length=120 11 | 12 | [MASTER] 13 | ignore=migrations -------------------------------------------------------------------------------- /mysite/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mysite/mysite/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for mysite project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /mysite/blog/templates/pagination.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mysite/blog/templates/blog/post/share.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block title %}Share a post{% endblock %} 4 | 5 | {% block content %} 6 | {% if sent %} 7 |

E-mail successfully sent

8 |

"{{ post.title }}" was successfully sent.

9 | {% else %} 10 |

Share "{{ post.title }}" by e-mail

11 |
12 | {% csrf_token %} 13 | {{ form.as_p }} 14 | 15 |
16 | {% endif %} 17 | {% endblock %} -------------------------------------------------------------------------------- /mysite/blog/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | 3 | """Settings tests""" 4 | 5 | from django.core import mail 6 | from django.test import TestCase 7 | 8 | 9 | class EmailTest(TestCase): 10 | 11 | def test_send_email(self): 12 | mail.send_mail( 13 | 'Subject here', 14 | 'Here is the message.', 15 | 'from@example.com', 16 | ['to@example.com'], 17 | fail_silently=False 18 | ) 19 | self.assertEqual(len(mail.outbox), 1) 20 | self.assertEqual(mail.outbox[0].subject, 'Subject here') 21 | -------------------------------------------------------------------------------- /mysite/blog/migrations/0002_auto_20160216_1442.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-02-16 14:42 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('blog', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='post', 18 | name='publish', 19 | field=models.DateTimeField(default=django.utils.timezone.now), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /mysite/blog/forms.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=too-few-public-methods, missing-docstring 2 | 3 | """Blog forms""" 4 | 5 | from django import forms 6 | 7 | from .models import Comment 8 | 9 | 10 | class EmailPostForm(forms.Form): 11 | """Post share form""" 12 | 13 | name = forms.CharField(max_length=25) 14 | sender = forms.EmailField() 15 | recipient = forms.EmailField() 16 | comments = forms.CharField(required=False, widget=forms.Textarea) 17 | 18 | 19 | class CommentForm(forms.ModelForm): 20 | """New comment form for post detail page""" 21 | 22 | class Meta: 23 | model = Comment 24 | fields = ('name', 'email', 'body') 25 | -------------------------------------------------------------------------------- /mysite/blog/static/css/blog.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing:border-box; 3 | } 4 | 5 | a { 6 | text-decoration: none; 7 | } 8 | 9 | body, 10 | html { 11 | height: 100%; 12 | margin: 0; 13 | } 14 | 15 | h1 { 16 | border-bottom: 1px solid lightgrey; 17 | padding-bottom: 10px; 18 | } 19 | 20 | .date, 21 | .info { 22 | color: grey; 23 | } 24 | 25 | .comment:nth-child(even) { 26 | background-color: whitesmoke; 27 | } 28 | 29 | #container, 30 | #sidebar { 31 | display: inline-block; 32 | float: left; 33 | padding: 20px; 34 | height: 100%; 35 | } 36 | 37 | #container { 38 | width: 70%; 39 | } 40 | 41 | #sidebar { 42 | width: 30%; 43 | background-color: lightgrey; 44 | } 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # Make changes in requirements.in, then run this to update: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | astroid==1.4.5 # via pylint 8 | colorama==0.3.7 # via pylint 9 | django-taggit==0.18.1 10 | Django==1.9.5 11 | factory-boy==2.6.1 12 | fake-factory==0.5.7 # via factory-boy 13 | lazy-object-proxy==1.2.2 # via astroid 14 | py==1.4.31 # via pytest 15 | pylint==1.5.5 16 | pytest==2.9.1 17 | python-dateutil==2.5.2 # via fake-factory 18 | pytz==2016.3 19 | selenium==2.53.1 20 | six==1.10.0 # via astroid, fake-factory, pylint, python-dateutil 21 | wrapt==1.10.8 # via astroid 22 | -------------------------------------------------------------------------------- /mysite/blog/migrations/0004_post_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.4 on 2016-03-29 13:09 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | import taggit.managers 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('taggit', '0002_auto_20150616_2121'), 13 | ('blog', '0003_comment'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='post', 19 | name='tags', 20 | field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /mysite/blog/urls.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # pylint: disable=invalid-name 3 | 4 | """Blog urls""" 5 | 6 | from django.conf.urls import url 7 | 8 | from . import views 9 | 10 | 11 | urlpatterns = [ 12 | url( 13 | r'^$', 14 | views.post_list, 15 | name='post_list' 16 | ), 17 | url( 18 | r'^tag/(?P[-\w]+)/$', 19 | views.post_list, 20 | name='post_list_by_tag' 21 | ), 22 | url( 23 | r'^(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[-\w]+)/$', 24 | views.post_detail, 25 | name='post_detail' 26 | ), 27 | url( 28 | r'^(?P\d+)/share/$', 29 | views.post_share, 30 | name='post_share' 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Travis CI settings 2 | 3 | language: python 4 | 5 | python: 6 | - '3.4' 7 | 8 | env: 9 | matrix: 10 | - DB=sqlite DJANGO_VERSION=1.9.2 11 | 12 | install: 13 | - pip install -U pip 14 | - pip install -r requirements.txt 15 | 16 | before_script: 17 | 18 | - export DISPLAY=:99.0 19 | - sh -e /etc/init.d/xvfb start 20 | - sleep 3 21 | - export EMAIL_HOST_USER="test@example.com" 22 | - export SECRET_KEY="78m5@7^h(lne^@08$0@dn5i%96v^*u@v+1zp9_5t!iagf5319v" 23 | - export EMAIL_HOST_PASSWORD="password" 24 | - python3 mysite/manage.py migrate 25 | - sleep 3 26 | 27 | script: 28 | - python3 mysite/manage.py test blog 29 | - python3 mysite/manage.py test mysite/functional_tests 30 | - python3 -m pylint mysite/blog 31 | -------------------------------------------------------------------------------- /mysite/blog/admin.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | 3 | """ 4 | Mysite admin panel 5 | """ 6 | 7 | from django.contrib import admin 8 | 9 | from .models import Post, Comment 10 | 11 | 12 | class PostAdmin(admin.ModelAdmin): 13 | 14 | list_display = ('title', 'slug', 'author', 'publish', 'status',) 15 | list_filter = ('status', 'created', 'publish', 'author',) 16 | search_fields = ('title', 'body',) 17 | prepopulated_fields = {'slug': ('title',)} 18 | raw_id_fields = ('author',) 19 | date_hierarchy = 'publish' 20 | ordering = ['status', 'publish'] 21 | 22 | admin.site.register(Post, PostAdmin) 23 | 24 | 25 | class CommentAdmin(admin.ModelAdmin): 26 | 27 | list_display = ('name', 'email', 'post', 'created', 'updated', 'active') 28 | list_filter = ('active', 'created', 'updated') 29 | search_fields = ('name', 'email', 'body') 30 | 31 | admin.site.register(Comment, CommentAdmin) 32 | -------------------------------------------------------------------------------- /mysite/blog/templates/blog/post/list.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block title %}My Blog{% endblock %} 4 | 5 | {% block content %} 6 |

My Blog

7 | {% if tag %} 8 |

Posts tagged with "{{ tag.name }}"

9 | {% endif %} 10 | {% for post in posts %} 11 |
12 |

{{ post.title }}

13 |

14 | Tags: 15 | {% for tag in post.tags.all %} 16 | {{ tag.name }} 17 | {% if not forloop.last %}, {% endif %} 18 | {% endfor %} 19 |

20 |

Published {{ post.publish }} by {{ post.author }}

21 | {{ post.body|truncatewords:50|linebreaks }} 22 |
23 | {% endfor %} 24 | {% include "pagination.html" with page=posts %} 25 | {% endblock %} -------------------------------------------------------------------------------- /mysite/blog/templatetags/blog_tags.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name 2 | 3 | """Custom template tags for blog app""" 4 | 5 | from django import template 6 | from django.db.models import Count 7 | 8 | from ..models import Post 9 | 10 | register = template.Library() 11 | 12 | 13 | @register.simple_tag 14 | def total_posts(): 15 | """Count number of published posts""" 16 | return Post.published.count() 17 | 18 | 19 | @register.inclusion_tag('blog/post/latest_posts.html') 20 | def show_latest_posts(count=5): 21 | """Insert block of latest posts 22 | 23 | Args: 24 | count (int): Number of posts 25 | """ 26 | latest_posts = Post.published.order_by('-publish')[:count] 27 | return {'latest_posts': latest_posts} 28 | 29 | 30 | @register.assignment_tag 31 | def get_most_commented_posts(count=5): 32 | """Return most commented posts 33 | 34 | Args: 35 | count (int): Number of posts 36 | """ 37 | return Post.published.annotate(total_comments=Count('comments'))\ 38 | .order_by('-total_comments')[:count] 39 | -------------------------------------------------------------------------------- /mysite/blog/templates/blog/base.html: -------------------------------------------------------------------------------- 1 | {% load blog_tags %} 2 | {% load staticfiles %} 3 | 4 | 5 | 6 | 7 | {% block title %}{% endblock %} 8 | 9 | 10 | 11 |
12 | {% block content %}{% endblock %} 13 |
14 | 33 | 34 | -------------------------------------------------------------------------------- /mysite/blog/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring, invalid-name 2 | 3 | """Module docstring""" 4 | 5 | from django.test import TestCase 6 | 7 | from blog.forms import EmailPostForm, CommentForm 8 | 9 | 10 | class EmailPostFormTest(TestCase): 11 | 12 | def test_form_validation_maximum_data(self): 13 | form = EmailPostForm(data={ 14 | 'name': 'user', 15 | 'sender': 'example@test.com', 16 | 'recipient': 'another@test.com', 17 | 'comments': 'some comments' 18 | }) 19 | self.assertTrue(form.is_valid()) 20 | 21 | def test_form_validation_minimum_data(self): 22 | form = EmailPostForm(data={ 23 | 'name': 'user', 24 | 'sender': 'example@test.com', 25 | 'recipient': 'another@test.com' 26 | }) 27 | self.assertTrue(form.is_valid()) 28 | 29 | 30 | class CommentFormTest(TestCase): 31 | 32 | def test_form_validation_maximum_data(self): 33 | form = CommentForm(data={ 34 | 'name': 'user', 35 | 'email': 'example@test.com', 36 | 'body': 'Sample comment body', 37 | }) 38 | self.assertTrue(form.is_valid()) 39 | -------------------------------------------------------------------------------- /mysite/blog/migrations/0003_comment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.4 on 2016-03-18 11:37 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('blog', '0002_auto_20160216_1442'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Comment', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=80)), 21 | ('email', models.EmailField(max_length=254)), 22 | ('body', models.TextField()), 23 | ('created', models.DateTimeField(auto_now_add=True)), 24 | ('updated', models.DateTimeField(auto_now=True)), 25 | ('active', models.BooleanField(default=True)), 26 | ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.Post')), 27 | ], 28 | options={ 29 | 'ordering': ('created',), 30 | }, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /mysite/blog/factories.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # pylint: disable=too-few-public-methods,missing-docstring 3 | 4 | """ 5 | Django Model Factories 6 | """ 7 | 8 | import factory 9 | 10 | from django.contrib.auth.models import User 11 | 12 | from blog import models 13 | 14 | 15 | class UserFactory(factory.DjangoModelFactory): 16 | 17 | username = factory.Sequence(lambda n: 'test_user_%s' % n) 18 | first_name = 'John' 19 | last_name = 'Doe' 20 | email = factory.LazyAttribute(lambda x: '%s@example.org' % x.username) 21 | is_staff = False 22 | 23 | class Meta: 24 | model = User 25 | 26 | 27 | class PostFactory(factory.DjangoModelFactory): 28 | 29 | title = factory.Sequence(lambda n: 'Sample Title %s' % n) 30 | slug = factory.Sequence(lambda n: 'sample-slug-%s' % n) 31 | author = factory.SubFactory(UserFactory) 32 | body = factory.Sequence(lambda n: 'Sample text %s' % n) 33 | 34 | class Meta: 35 | model = models.Post 36 | 37 | 38 | class CommentFactory(factory.DjangoModelFactory): 39 | 40 | post = factory.SubFactory(PostFactory) 41 | name = factory.Sequence(lambda n: 'Sample Name %s' % n) 42 | email = factory.Sequence(lambda n: 'email_%s@example.com' % n) 43 | body = factory.Sequence(lambda n: 'Sample Text %s' % n) 44 | 45 | class Meta: 46 | model = models.Comment 47 | -------------------------------------------------------------------------------- /mysite/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-02-16 14:39 3 | from __future__ import unicode_literals 4 | 5 | import datetime 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | from django.utils.timezone import utc 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='Post', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('title', models.CharField(max_length=250)), 26 | ('slug', models.SlugField(max_length=250, unique_for_date='publish')), 27 | ('body', models.TextField()), 28 | ('publish', models.DateTimeField(default=datetime.datetime(2016, 2, 16, 14, 39, 29, 536943, tzinfo=utc))), 29 | ('created', models.DateTimeField(auto_now_add=True)), 30 | ('updated', models.DateTimeField(auto_now=True)), 31 | ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=10)), 32 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blog_posts', to=settings.AUTH_USER_MODEL)), 33 | ], 34 | options={ 35 | 'ordering': ('-publish',), 36 | }, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /mysite/blog/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring, invalid-name 2 | 3 | """Custom template tags tests""" 4 | 5 | from django.test import TestCase 6 | 7 | from blog.factories import PostFactory, CommentFactory 8 | from blog.templatetags.blog_tags import total_posts, show_latest_posts, get_most_commented_posts 9 | 10 | 11 | class TotalPostsTest(TestCase): 12 | 13 | def setUp(self): 14 | for _ in range(3): 15 | PostFactory() 16 | PostFactory(status='published') 17 | 18 | def test_returns_correct_result(self): 19 | self.assertEqual(total_posts(), 3) 20 | 21 | 22 | class ShowLatestPosts(TestCase): 23 | 24 | def setUp(self): 25 | self.posts = [PostFactory(status='published') for _ in range(10)] 26 | 27 | def test_returns_correct_result_with_default_argument(self): 28 | self.assertEqual(list(show_latest_posts()['latest_posts']), self.posts[::-1][:5]) 29 | 30 | def test_returns_correct_result_with_non_default_argument(self): 31 | self.assertEqual(list(show_latest_posts(3)['latest_posts']), self.posts[::-1][:3]) 32 | 33 | 34 | class GetMostCommentedPostsTest(TestCase): 35 | 36 | def setUp(self): 37 | self.posts = [] 38 | 39 | for i in range(5): 40 | post = PostFactory(status='published') 41 | self.posts.append(post) 42 | for _ in range(i): 43 | CommentFactory(post=post) 44 | 45 | def test_returns_correct_result_with_default_argument(self): 46 | self.assertEqual(list(get_most_commented_posts()), self.posts[::-1]) 47 | 48 | def test_returns_correct_result_with_non_default_argument(self): 49 | self.assertEqual(list(get_most_commented_posts(3)), self.posts[::-1][:3]) 50 | -------------------------------------------------------------------------------- /mysite/blog/templates/blog/post/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base.html" %} 2 | 3 | {% block title %}{{ post.title }}{% endblock %} 4 | 5 | {% block content %} 6 |

{{ post.title }}

7 |

Published {{ post.publish }} by {{ post.author }}

8 | {{ post.body|linebreaks }} 9 |

Share this post

10 | 11 | {# Similar posts #} 12 |
13 |

Similar posts

14 | {% for post in similar_posts %} 15 |

{{ post.title }}

16 | {% empty %} 17 |

There are no similar posts yet.

18 | {% endfor %} 19 |
20 | 21 | {# Number of comments #} 22 | {% with comments.count as total_comments %} 23 |

{{ total_comments }} comment{{ total_comments|pluralize }}

24 | {% endwith %} 25 | 26 | {# Comments #} 27 | {% for comment in comments %} 28 |
29 |

30 | Comment {{ forloop.counter }} by {{ comment.name }} 31 | {{ comment.created }} 32 |

33 | {{ comment.body|linebreaks }} 34 |
35 | {% empty %} 36 |

There are no comments yet.

37 | {% endfor %} 38 | 39 | {# New comment form #} 40 | {% if new_comment %} 41 |

Your comment has been added.

42 | {% else %} 43 |

Add a new comment

44 |
45 | {% csrf_token %} 46 | {{ comment_form.as_p }} 47 |

48 |
49 | {% endif %} 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /mysite/functional_tests/test_post_share.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring, no-member 2 | 3 | """Post share page test""" 4 | 5 | from selenium import webdriver 6 | 7 | from django.contrib.staticfiles.testing import StaticLiveServerTestCase 8 | from django.core import mail 9 | 10 | from blog.factories import PostFactory 11 | 12 | 13 | class TestPostShare(StaticLiveServerTestCase): 14 | 15 | def setUp(self): 16 | self.browser = webdriver.Firefox() 17 | self.post = PostFactory(status='published') 18 | 19 | def tearDown(self): 20 | self.browser.quit() 21 | 22 | def test_post_share(self): 23 | # User can saw share link on a post detail page 24 | self.browser.get(self.live_server_url + self.post.get_absolute_url()) 25 | share_url = self.browser.find_element_by_link_text('Share this post') 26 | 27 | # He click on it and redirects to share form 28 | share_url.click() 29 | self.assertEqual(self.browser.find_element_by_tag_name('h1').text, 'Share "%s" by e-mail' % self.post.title) 30 | 31 | # He clicks send button and sees required errors 32 | self.browser.find_element_by_id('send').click() 33 | self.assertEqual(len(self.browser.find_elements_by_class_name('errorlist')), 3) 34 | 35 | # He fills out the form and clicks send, than he sees a success page 36 | self.browser.find_element_by_id('id_name').send_keys('user') 37 | self.browser.find_element_by_id('id_sender').send_keys('user@example.com') 38 | self.browser.find_element_by_id('id_recipient').send_keys('to@example.com') 39 | self.browser.find_element_by_id('id_comments').send_keys('sample comment') 40 | self.browser.find_element_by_id('send').click() 41 | self.assertEqual(self.browser.find_element_by_tag_name('h1').text, 'E-mail successfully sent') 42 | 43 | # Email has been sent 44 | self.assertEqual(len(mail.outbox), 1) 45 | subject = 'user (user@example.com) recommends you reading "%s"' % self.post.title 46 | self.assertEqual(mail.outbox[0].subject, subject) 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | ### JetBrains template 62 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 63 | 64 | *.iml 65 | 66 | ## Directory-based project format: 67 | .idea/ 68 | # if you remove the above rule, at least ignore the following: 69 | 70 | # User-specific stuff: 71 | # .idea/workspace.xml 72 | # .idea/tasks.xml 73 | # .idea/dictionaries 74 | 75 | # Sensitive or high-churn files: 76 | # .idea/dataSources.ids 77 | # .idea/dataSources.xml 78 | # .idea/sqlDataSources.xml 79 | # .idea/dynamic.xml 80 | # .idea/uiDesigner.xml 81 | 82 | # Gradle: 83 | # .idea/gradle.xml 84 | # .idea/libraries 85 | 86 | # Mongo Explorer plugin: 87 | # .idea/mongoSettings.xml 88 | 89 | ## File-based project format: 90 | *.ipr 91 | *.iws 92 | 93 | ## Plugin-specific files: 94 | 95 | # IntelliJ 96 | /out/ 97 | 98 | # mpeltonen/sbt-idea plugin 99 | .idea_modules/ 100 | 101 | # JIRA plugin 102 | atlassian-ide-plugin.xml 103 | 104 | # Crashlytics plugin (for Android Studio and IntelliJ) 105 | com_crashlytics_export_strings.xml 106 | crashlytics.properties 107 | crashlytics-build.properties 108 | 109 | ### Custom 110 | 111 | # SQLite database 112 | db.sqlite3 113 | -------------------------------------------------------------------------------- /mysite/blog/models.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring, too-few-public-methods, no-member 2 | 3 | """ 4 | Blog app models 5 | """ 6 | 7 | from django.db import models 8 | from django.utils import timezone 9 | from django.contrib.auth.models import User 10 | from django.core.urlresolvers import reverse 11 | 12 | from taggit.managers import TaggableManager 13 | 14 | 15 | class PublishedManager(models.Manager): 16 | 17 | def get_queryset(self): 18 | return super(PublishedManager, self).get_queryset().filter(status='published') 19 | 20 | 21 | class Post(models.Model): 22 | """Blog posts""" 23 | 24 | STATUS_CHOICE = ( 25 | ('draft', 'Draft'), 26 | ('published', 'Published'), 27 | ) 28 | title = models.CharField(max_length=250) 29 | slug = models.SlugField(max_length=250, unique_for_date='publish') 30 | author = models.ForeignKey(User, related_name='blog_posts') 31 | body = models.TextField() 32 | publish = models.DateTimeField(default=timezone.now) 33 | created = models.DateTimeField(auto_now_add=True) 34 | updated = models.DateTimeField(auto_now=True) 35 | status = models.CharField(max_length=10, choices=STATUS_CHOICE, default='draft') 36 | 37 | objects = models.Manager() # Default model manager 38 | published = PublishedManager() # Custom model manager 39 | tags = TaggableManager(blank=True) 40 | 41 | class Meta: 42 | ordering = ('-publish',) 43 | 44 | def __str__(self): 45 | return self.title 46 | 47 | def get_absolute_url(self): 48 | return reverse( 49 | 'blog:post_detail', 50 | args=[ 51 | self.publish.year, 52 | self.publish.strftime('%m'), 53 | self.publish.strftime('%d'), 54 | self.slug 55 | ] 56 | ) 57 | 58 | 59 | class Comment(models.Model): 60 | post = models.ForeignKey(Post, related_name='comments') 61 | name = models.CharField(max_length=80) 62 | email = models.EmailField() 63 | body = models.TextField() 64 | created = models.DateTimeField(auto_now_add=True) 65 | updated = models.DateTimeField(auto_now=True) 66 | active = models.BooleanField(default=True) # To deactivate inappropriate comments 67 | 68 | class Meta: 69 | ordering = ('created',) 70 | 71 | def __str__(self): 72 | return 'Comment by %s on %s' % (self.name, self.post) 73 | -------------------------------------------------------------------------------- /mysite/blog/tests/test_models.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | # pylint: disable=missing-docstring,no-member,invalid-name 4 | 5 | """Blog models test""" 6 | 7 | from django.core.exceptions import ValidationError 8 | from django.test import TestCase 9 | 10 | from blog.models import Post, Comment 11 | from blog.factories import PostFactory, UserFactory, CommentFactory 12 | 13 | 14 | class TestPost(TestCase): 15 | 16 | def test_default_status_is_draft(self): 17 | self.assertEqual('draft', PostFactory().status) 18 | 19 | def test_can_not_create_two_posts_with_same_slug_and_date(self): 20 | post = PostFactory() 21 | self.assertRaises(ValidationError, Post(slug=post.slug, publish=post.publish).validate_unique) 22 | 23 | def test_can_get_user_posts(self): 24 | user1 = UserFactory() 25 | user2 = UserFactory() 26 | post1 = PostFactory(author=user1) 27 | PostFactory(author=user2) 28 | post3 = PostFactory(author=user1) 29 | user1_posts = user1.blog_posts.all() 30 | 31 | self.assertEqual(len(user1_posts), 2) 32 | self.assertIn(post1, user1_posts) 33 | self.assertIn(post3, user1_posts) 34 | 35 | def test_post_order_is_reverse_publish(self): 36 | post1 = PostFactory() 37 | post2 = PostFactory() 38 | post3 = PostFactory() 39 | self.assertEqual(list(Post.objects.all()), [post3, post2, post1]) 40 | 41 | def test_absolute_url(self): 42 | post = PostFactory() 43 | self.assertEqual(post.get_absolute_url(), '/blog/%04d/%02d/%02d/%s/' % ( 44 | int(post.publish.year), int(post.publish.month), int(post.publish.day), post.slug)) 45 | 46 | 47 | class TestPublishedManager(TestCase): 48 | 49 | def test_published_manager_returns_only_published_posts(self): 50 | post1 = PostFactory(status='published') 51 | PostFactory() 52 | post3 = PostFactory(status='published') 53 | self.assertListEqual(list(Post.published.all()), [post3, post1]) 54 | 55 | 56 | class TestComments(TestCase): 57 | 58 | def test_comments_are_ordered_by_created_time(self): 59 | comment1 = CommentFactory() 60 | comment2 = CommentFactory() 61 | comment3 = CommentFactory() 62 | self.assertEqual(list(Comment.objects.all()), [comment1, comment2, comment3]) 63 | 64 | def test_can_get_all_post_comments(self): 65 | post = PostFactory() 66 | comment1 = CommentFactory(post=post) 67 | CommentFactory() # Comment from another post 68 | comment3 = CommentFactory(post=post) 69 | self.assertEqual(list(post.comments.all()), [comment1, comment3]) 70 | 71 | def test_comment_str_representation(self): 72 | comment = CommentFactory() 73 | self.assertEqual(comment.__str__(), 'Comment by %s on %s' % (comment.name, comment.post.title)) 74 | -------------------------------------------------------------------------------- /mysite/functional_tests/test_post_detail.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # pylint: disable=missing-docstring, no-member 3 | 4 | """Post detail page test""" 5 | 6 | from selenium import webdriver 7 | from pytz import UTC 8 | 9 | from django.contrib.staticfiles.testing import StaticLiveServerTestCase 10 | from django.utils import timezone 11 | 12 | from blog.factories import PostFactory 13 | 14 | 15 | class TestPostDetail(StaticLiveServerTestCase): 16 | 17 | def setUp(self): 18 | self.browser = webdriver.Firefox() 19 | self.post = PostFactory(status='published', publish=timezone.datetime(2016, 10, 11, 2, 27, tzinfo=UTC)) 20 | self.post.tags.add('tag0', 'tag1') 21 | 22 | # He create and tag few posts 23 | self.post1 = PostFactory(status='published') 24 | self.post2 = PostFactory(status='published') 25 | self.post3 = PostFactory(status='published') 26 | 27 | self.post1.tags.add('tag0', 'tag1') 28 | self.post2.tags.add('tag0') 29 | 30 | # User goes to the post page 31 | self.browser.get(self.live_server_url + self.post.get_absolute_url()) 32 | 33 | def tearDown(self): 34 | self.browser.quit() 35 | 36 | def add_comment(self, name, email, body): 37 | self.browser.find_element_by_id('id_name').send_keys(name) 38 | self.browser.find_element_by_id('id_email').send_keys(email) 39 | self.browser.find_element_by_id('id_body').send_keys(body) 40 | self.browser.find_element_by_css_selector('input[type=submit]').click() 41 | 42 | def test_post_detail(self): 43 | # He sees the post title, date, author and body 44 | self.assertEqual(self.browser.find_element_by_css_selector('h1').text, self.post.title) 45 | self.assertEqual(self.browser.find_element_by_css_selector('#container p:nth-child(3)').text, self.post.body) 46 | date_author = 'Published Oct. 11, 2016, 5:27 a.m. by %s' % self.post.author.username 47 | self.assertEqual(self.browser.find_element_by_class_name('date').text, date_author) 48 | 49 | def test_comments(self): 50 | # He does not see any comments yet 51 | self.assertEqual(self.browser.find_element_by_id('comments-counter').text, '0 comments') 52 | self.assertEqual(self.browser.find_element_by_id('empty').text, 'There are no comments yet.') 53 | 54 | # But he see a form to enter a new comment 55 | self.browser.find_element_by_id('new-comment') 56 | 57 | # He adds a comment 58 | self.add_comment('user1', 'user1@example.com', 'First comment body') 59 | 60 | # And sees success message 61 | self.assertEqual(self.browser.find_element_by_id('adding-success').text, 'Your comment has been added.') 62 | 63 | # And his comment 64 | self.assertEqual( 65 | self.browser.find_element_by_css_selector('.comment p:last-child').text, 66 | 'First comment body' 67 | ) 68 | 69 | # He refreshes the page and adds two more comments 70 | self.browser.get(self.live_server_url + self.post.get_absolute_url()) 71 | self.add_comment('user2', 'user2@example.com', 'Second comment body') 72 | self.browser.get(self.live_server_url + self.post.get_absolute_url()) 73 | self.add_comment('user3', 'user3@example.com', 'Third comment body') 74 | 75 | # He sees all comments in right order 76 | comments = [comment.text for comment in self.browser.find_elements_by_css_selector('.comment p:last-child')] 77 | self.assertEqual(comments, ['First comment body', 'Second comment body', 'Third comment body']) 78 | 79 | def test_similar_posts(self): 80 | # He can see similar posts 81 | similar_posts = [post.text for post in self.browser.find_elements_by_css_selector('.similar-posts p a')] 82 | self.assertEqual(similar_posts, [self.post1.title, self.post2.title]) 83 | 84 | # There is no similar posts for post3 85 | self.browser.get(self.live_server_url + self.post3.get_absolute_url()) 86 | self.assertEqual( 87 | self.browser.find_element_by_css_selector('.similar-posts p').text, 88 | 'There are no similar posts yet.' 89 | ) 90 | -------------------------------------------------------------------------------- /mysite/mysite/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for mysite project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.9.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from django.core.exceptions import ImproperlyConfigured 16 | 17 | 18 | def get_env_variable(var_name): 19 | try: 20 | return os.environ[var_name] 21 | except KeyError: 22 | error_msg = "Set the %s environment variable" % var_name 23 | raise ImproperlyConfigured(error_msg) 24 | 25 | 26 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 27 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 28 | 29 | 30 | # Quick-start development settings - unsuitable for production 31 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 32 | 33 | # SECURITY WARNING: keep the secret key used in production secret! 34 | SECRET_KEY = get_env_variable('SECRET_KEY') 35 | 36 | # SECURITY WARNING: don't run with debug turned on in production! 37 | DEBUG = True 38 | 39 | ALLOWED_HOSTS = [] 40 | 41 | 42 | # Application definition 43 | 44 | INSTALLED_APPS = [ 45 | 'django.contrib.admin', 46 | 'django.contrib.auth', 47 | 'django.contrib.contenttypes', 48 | 'django.contrib.sessions', 49 | 'django.contrib.messages', 50 | 'django.contrib.staticfiles', 51 | 'blog', 52 | 'taggit' 53 | ] 54 | 55 | MIDDLEWARE_CLASSES = [ 56 | 'django.middleware.security.SecurityMiddleware', 57 | 'django.contrib.sessions.middleware.SessionMiddleware', 58 | 'django.middleware.common.CommonMiddleware', 59 | 'django.middleware.csrf.CsrfViewMiddleware', 60 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 61 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 62 | 'django.contrib.messages.middleware.MessageMiddleware', 63 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 64 | ] 65 | 66 | ROOT_URLCONF = 'mysite.urls' 67 | 68 | TEMPLATES = [ 69 | { 70 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 71 | 'DIRS': [], 72 | 'APP_DIRS': True, 73 | 'OPTIONS': { 74 | 'context_processors': [ 75 | 'django.template.context_processors.debug', 76 | 'django.template.context_processors.request', 77 | 'django.contrib.auth.context_processors.auth', 78 | 'django.contrib.messages.context_processors.messages', 79 | ], 80 | }, 81 | }, 82 | ] 83 | 84 | WSGI_APPLICATION = 'mysite.wsgi.application' 85 | 86 | 87 | # Database 88 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 89 | 90 | DATABASES = { 91 | 'default': { 92 | 'ENGINE': 'django.db.backends.sqlite3', 93 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 94 | } 95 | } 96 | 97 | 98 | # Password validation 99 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 100 | 101 | AUTH_PASSWORD_VALIDATORS = [ 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 104 | }, 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 107 | }, 108 | { 109 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 110 | }, 111 | { 112 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 113 | }, 114 | ] 115 | 116 | 117 | # Internationalization 118 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 119 | 120 | LANGUAGE_CODE = 'en-us' 121 | 122 | TIME_ZONE = 'Europe/Moscow' 123 | 124 | USE_I18N = True 125 | 126 | USE_L10N = True 127 | 128 | USE_TZ = True 129 | 130 | 131 | # Static files (CSS, JavaScript, Images) 132 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 133 | 134 | STATIC_URL = '/static/' 135 | 136 | 137 | # Email settings 138 | 139 | EMAIL_HOST = 'smtp.gmail.com' 140 | EMAIL_HOST_USER = get_env_variable('EMAIL_HOST_USER') 141 | EMAIL_HOST_PASSWORD = get_env_variable('EMAIL_HOST_PASSWORD') 142 | EMAIL_PORT = 587 143 | EMAIL_USE_TLS = True 144 | -------------------------------------------------------------------------------- /mysite/blog/views.py: -------------------------------------------------------------------------------- 1 | """Blog views""" 2 | 3 | from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage 4 | from django.core.mail import send_mail 5 | from django.db.models import Count 6 | from django.shortcuts import render, get_object_or_404 7 | 8 | from taggit.models import Tag 9 | 10 | from .models import Post 11 | from .forms import EmailPostForm, CommentForm 12 | 13 | 14 | def post_list(request, tag_slug=None): 15 | """Shows post list page 16 | 17 | Args: 18 | request: HTTP Request 19 | tag_slug (str): Tag slug to filter posts 20 | """ 21 | object_list = Post.published.all() 22 | tag = None 23 | 24 | if tag_slug: 25 | tag = get_object_or_404(Tag, slug=tag_slug) 26 | object_list = object_list.filter(tags__in=[tag]) 27 | 28 | paginator = Paginator(object_list, 3) 29 | page = request.GET.get('page') 30 | try: 31 | posts = paginator.page(page) 32 | except PageNotAnInteger: 33 | # If page is not an integer deliver first page 34 | posts = paginator.page(1) 35 | except EmptyPage: 36 | # If page is out of range deliver last page of results 37 | posts = paginator.page(paginator.num_pages) 38 | return render(request, 'blog/post/list.html', { 39 | 'page': page, 40 | 'posts': posts, 41 | 'tag': tag 42 | }) 43 | 44 | 45 | def post_detail(request, year, month, day, post): 46 | """Show detail post page 47 | 48 | Args: 49 | request: HTTP Request 50 | year (str): '1972' 51 | month (str): '13' 52 | day (str): '20' 53 | post (str): post-slug 54 | """ 55 | post = get_object_or_404( 56 | Post, 57 | slug=post, 58 | status='published', 59 | publish__year=year, 60 | publish__month=month, 61 | publish__day=day 62 | ) 63 | 64 | # Is there a new comment? 65 | new_comment = None 66 | 67 | # List of active comments of a given post 68 | comments = post.comments.filter(active=True) 69 | 70 | if request.method == 'POST': 71 | # A comment was posted 72 | comment_form = CommentForm(data=request.POST) 73 | if comment_form.is_valid(): 74 | # Create Comment object but don't save to database yet 75 | new_comment = comment_form.save(commit=False) 76 | # Assign the current post to the comment 77 | new_comment.post = post 78 | # Save the comment to the database 79 | new_comment.save() 80 | else: 81 | comment_form = CommentForm() 82 | 83 | # List of similar posts 84 | post_tags_ids = post.tags.values_list('id', flat=True) 85 | similar_posts = Post.published.filter(tags__in=post_tags_ids).exclude(id=post.id) 86 | similar_posts = similar_posts.annotate(same_tags=Count('tags')).order_by('-same_tags', '-publish')[:4] 87 | 88 | return render(request, 'blog/post/detail.html', { 89 | 'post': post, 90 | 'comments': comments, 91 | 'comment_form': comment_form, 92 | 'new_comment': new_comment, 93 | 'similar_posts': similar_posts 94 | }) 95 | 96 | 97 | def post_share(request, post_id): 98 | """Post share view 99 | 100 | Args: 101 | request: HTTP Request 102 | post_id: Shared post id 103 | """ 104 | # Retrieve post by id 105 | post = get_object_or_404(Post, id=post_id, status='published') 106 | sent = False 107 | 108 | if request.method == 'POST': 109 | # From was submitted 110 | form = EmailPostForm(request.POST) 111 | if form.is_valid(): 112 | # From fields passed validation 113 | cleaned_data = form.cleaned_data 114 | post_url = request.build_absolute_uri(post.get_absolute_url()) 115 | subject = '%s (%s) recommends you reading "%s"' % ( 116 | cleaned_data['name'], cleaned_data['sender'], post.title,) 117 | message = 'Read "%s" at %s\n\n%s\'s comments: %s' % ( 118 | post.title, post_url, cleaned_data['sender'], cleaned_data['comments']) 119 | send_mail(subject, message, 'admin@myblog.com', [cleaned_data['recipient']]) 120 | sent = True 121 | else: 122 | form = EmailPostForm() 123 | return render(request, 'blog/post/share.html', { 124 | 'post': post, 125 | 'form': form, 126 | 'sent': sent 127 | }) 128 | -------------------------------------------------------------------------------- /mysite/functional_tests/test_post_list.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # pylint: disable=missing-docstring, no-member, attribute-defined-outside-init, invalid-name 3 | # pylint: disable=too-many-instance-attributes 4 | 5 | """Post list page test""" 6 | 7 | from selenium import webdriver 8 | 9 | from django.contrib.staticfiles.testing import StaticLiveServerTestCase 10 | 11 | from blog.factories import PostFactory, CommentFactory 12 | 13 | 14 | class TestPostList(StaticLiveServerTestCase): 15 | 16 | def setUp(self): 17 | self.post0 = PostFactory(status='published') 18 | self.post1 = PostFactory(status='published') 19 | self.post2 = PostFactory() 20 | self.post3 = PostFactory(status='published') 21 | self.post4 = PostFactory(status='published') 22 | 23 | self.post3.tags.add('test_tag_0') 24 | self.post4.tags.add('test_tag_0') 25 | self.post4.tags.add('test_tag_1') 26 | 27 | self.browser = webdriver.Firefox() 28 | self.browser.get(self.live_server_url + '/blog/') # Go to the main page 29 | 30 | def tearDown(self): 31 | self.browser.quit() 32 | 33 | def can_see_actual_posts(self): 34 | posts = self.browser.find_elements_by_css_selector('#container a') 35 | post_titles = [post.text for post in posts] 36 | for post in [self.post4, self.post3, self.post1]: 37 | self.assertIn(post.title, post_titles) 38 | for post in [self.post2, self.post0]: 39 | self.assertNotIn(post.title, post_titles) 40 | 41 | def test_basic_post_list(self): 42 | # He sees the list of published posts 43 | self.can_see_actual_posts() 44 | 45 | # He can go to the second page and see old post 46 | self.browser.find_element_by_css_selector('.step-links a').click() 47 | self.assertEqual(self.browser.find_element_by_css_selector('#container a').text, self.post0.title) 48 | 49 | # And go back to the first page and see actual posts 50 | self.browser.find_element_by_css_selector('.step-links a').click() 51 | self.can_see_actual_posts() 52 | 53 | # He can go to specific post by click on it's title 54 | self.browser.find_element_by_css_selector('#container a').click() 55 | self.assertEqual(self.browser.find_element_by_tag_name('h1').text, self.post4.title) 56 | self.assertEqual(self.browser.current_url, self.live_server_url + self.post4.get_absolute_url()) 57 | 58 | def test_post_list_tags(self): 59 | # He sees post4 has two tags 60 | posts = self.browser.find_elements_by_class_name('post') 61 | tags = posts[0].find_elements_by_css_selector('.tags a') 62 | self.assertListEqual([tag.text for tag in tags], ['test_tag_0', 'test_tag_1']) 63 | 64 | # And post3 has one tag 65 | tags = posts[1].find_elements_by_css_selector('.tags a') 66 | self.assertEqual([tag.text for tag in tags], ['test_tag_0']) 67 | 68 | # He clicks on the test_tag_0 and post list are filtered 69 | tags[0].click() 70 | self.assertEqual(self.browser.find_element_by_tag_name('h2').text, 'Posts tagged with "test_tag_0"') 71 | titles = [title.text for title in self.browser.find_elements_by_css_selector('.post h2 a')] 72 | self.assertListEqual(titles, [self.post4.title, self.post3.title]) 73 | 74 | def test_blog_tags__total_posts(self): 75 | """Custom total_posts tag works fine""" 76 | # User sees number of published posts in the sidebar welcome message 77 | sidebar = self.browser.find_element_by_id('sidebar') 78 | welcome_message = sidebar.find_element_by_tag_name('p').text 79 | self.assertIn('4', welcome_message) 80 | 81 | def test_blog_tags__show_latest_posts(self): 82 | """Custom show_latest_posts tag works fine""" 83 | self.post5 = PostFactory(status='published') 84 | self.post6 = PostFactory(status='published') 85 | self.browser.get(self.live_server_url + '/blog/') 86 | 87 | # User sees five latest posts 88 | expected_posts = [self.post6, self.post5, self.post4, self.post3, self.post1] 89 | actual_post_elements = self.browser.find_elements_by_css_selector('#latest-posts a') 90 | actual_post_titles = [post.text for post in actual_post_elements] 91 | self.assertEqual(actual_post_titles, [post.title for post in expected_posts]) 92 | 93 | def test_blog_tags__get_most_commented_posts(self): 94 | """Custom get_most_commented_posts tag works fine""" 95 | for _ in range(4): 96 | CommentFactory(post=self.post4) 97 | for _ in range(3): 98 | CommentFactory(post=self.post1) 99 | for _ in range(2): 100 | CommentFactory(post=self.post3) 101 | self.browser.get(self.live_server_url + '/blog/') 102 | 103 | # User sees most commented posts list 104 | expected_posts = [self.post4, self.post1, self.post3, self.post0] 105 | actual_post_elements = self.browser.find_elements_by_css_selector('#most-commented-posts a') 106 | actual_post_titles = [post.text for post in actual_post_elements] 107 | self.assertEqual(actual_post_titles, [post.title for post in expected_posts]) 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Requirements Status](https://requires.io/github/lancelote/django_by_example/requirements.svg?branch=master)](https://requires.io/github/lancelote/django_by_example/requirements/?branch=master) 2 | [![Build Status](https://travis-ci.org/lancelote/django_by_example.svg)](https://travis-ci.org/lancelote/django_by_example) 3 | 4 | # django_by_example 5 | 6 | - Code for [Django by Example book](http://www.amazon.com/Django-Example-Antonio-Mele/dp/1784391913) by Antonio Mele 7 | - Archived due to a new book edition: [Django 2 by Example](https://github.com/lancelote/django_2_by_example) 8 | 9 | ## My Progress 10 | 11 | - [x] Chapter 1: Building a Blog Application 12 | - [x] Chapter 2: Enhancing Your Blog with Advanced Features 13 | - [ ] Chapter 3: Extending Your Blog Application 14 | - [ ] Chapter 4: Building a Social Website 15 | - [ ] Chapter 5: Sharing Content in Your Website 16 | - [ ] Chapter 6: Tracking User Actions 17 | - [ ] Chapter 7: Building an Online Shop 18 | - [ ] Chapter 8: Managing Payments and Orders 19 | - [ ] Chapter 9: Extending Your Shop 20 | - [ ] Chapter 10: Building an e-Learning Platform 21 | - [ ] Chapter 11: Caching Content 22 | - [ ] Chapter 12: Building an API 23 | 24 | ## Environment Variables 25 | 26 | All sensitive data (user names, passwords, Django secret key and etc) should be stored via environment variables 27 | and will be load by `os.environ`. Any missing values will raise `ImproperlyConfigured` exception. List of required 28 | environment variables: 29 | 30 | | Variable | Value | 31 | | --- | --- | 32 | | `SECRET_KEY` | Django secret key | 33 | | `EMAIL_HOST_USER` | Google email account name to send mails from django (example@gmail.com) | 34 | | `EMAIL_HOST_PASSWORD` | Google account password or application specific password for two factor authentication | 35 | 36 | I use two ways to set environment variables: via Pycharm (my primary IDE for development) and via `virtualenvwrapper` 37 | scripts. 38 | 39 | ### Setting Environment Variables with Pycharm 40 | 41 | There're few places where you can specify environment variables: 42 | 43 | 1. Run Configurations 44 | 2. Python and Django console 45 | 3. manage.py Pycharm settings 46 | 47 | #### Run Configurations 48 | 49 | This allows you to run development server, tests and so on via Pycharm `Run...` command without `ImproperlyConfigured`: 50 | 51 | - Open *Run Configuration* window - *Run* menu - *Edit Configurations...* or `Alt + Shift + F10` - `0` 52 | - Add *Django Server* (for example) via `Alt + Insert` 53 | - Find *Environment Variable* block and click `...` button to the right 54 | - Here you can specify names and values of the desired environment variables, note *copy* and *paste* buttons, they 55 | can be pretty useful 56 | 57 | #### Python and Django Console 58 | 59 | This will prevent `ImproperlyConfigured` upon opening Python console: 60 | 61 | - Open *File* menu - *Settings* - *Build, Execution, Deployment* - *Console* 62 | - There you have Django and Python consoles, you can specify any environment variable you want to load with them 63 | 64 | #### manage.py Pycharm Settings 65 | 66 | This will prevent `ImproperlyConfigured` exception upon opening manage.py Pycharm console (`Ctrl + Shift + R`): 67 | 68 | - *File* - *Settings* - *Languages & Frameworks* - *Django* - *Environment variables* 69 | 70 | ## Setting Environment Variables with `virtualenvwrapper` Scripts 71 | 72 | `virtualenvwrapper` provides really useful bash scripts, that will be executed upon virtualenv start, stop and so on. 73 | They are easy to use, we just need to export our environment variables to the global space right after virtualenv 74 | activation and to unset them upon virtualenv deactivation: 75 | 76 | - `workon ` 77 | - `cdvirtualenv` - to `cd` virtualenv folder (you can do it manually for sure) 78 | - `cd bin` - here we have out bash scripts 79 | - Add to `postactivate` this line: `export ENV_NAME="env_value"` where `ENV_NAME` is desired variable name, and 80 | `end_value` - it's value, copy this line if you need to setup few variables 81 | - Add to `predeactivate` this line (or lines): `unset ENV_NAME` for each `ENV_NAME` you add to `postactivate` 82 | 83 | ## Requirements 84 | 85 | ### Installation 86 | 87 | - Python 3+ 88 | - Virtualenv usage is recommended 89 | - To install requirements: `pip install -r requirements.txt` or `pip-sync` (`pip-tools` is required) 90 | 91 | ### Update 92 | 93 | - Install `pip-tools` 94 | - Update `requirements.in` 95 | - Compile `requirements.txt` by running `pip-compile requirements.in` 96 | 97 | ## Testing 98 | 99 | ### Tests 100 | 101 | All integration tests: 102 | ```bash 103 | python3 mysite/manage.py test blog.tests 104 | ``` 105 | 106 | Acceptance tests (selenium, Firefox is required), will take a while (but pretty fun to watch): 107 | ```bash 108 | python3 mysite/manage.py test functional_tests 109 | ``` 110 | 111 | > PLEASE NOTE: My acceptance tests are rather fragile and can fail due to some silly causes depends on OS and so on. 112 | > If it's your case - consider to [report an issue](https://github.com/lancelote/django_by_example/issues/new), 113 | > I would love to fix it. 114 | 115 | ### Syntax Validation 116 | 117 | ```bash 118 | python3 -m pylint mysite/blog/ mysite/functional_tests/ 119 | ``` 120 | 121 | ## Misc 122 | 123 | If you have any questions - feel free to ask me, I would love to help fellow learner! The book is awesome, but I 124 | found few typos/misleading notes so can probably you. 125 | -------------------------------------------------------------------------------- /mysite/blog/tests/test_views.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring, invalid-name, no-member 2 | 3 | """Blog views tests""" 4 | 5 | from django.core import mail 6 | from django.test import TestCase 7 | 8 | from taggit.models import Tag 9 | 10 | from blog.factories import PostFactory 11 | from blog.models import Comment 12 | 13 | 14 | class PostListTest(TestCase): 15 | 16 | def test_post_list_page_renders_list_template(self): 17 | response = self.client.get('/blog/') 18 | self.assertTemplateUsed(response, 'blog/post/list.html') 19 | 20 | def test_returns_correct_list_of_posts(self): 21 | post1 = PostFactory(status='published') 22 | post2 = PostFactory() 23 | post3 = PostFactory(status='published') 24 | response = self.client.get('/blog/') 25 | self.assertContains(response, post1.title) 26 | self.assertNotContains(response, post2.title) 27 | self.assertContains(response, post3.title) 28 | 29 | def test_returns_only_3_last_posts_by_default(self): 30 | posts = [PostFactory(status='published') for _ in range(4)] 31 | response = self.client.get('/blog/') 32 | self.assertEqual(list(response.context['posts']), posts[1:][::-1]) 33 | 34 | def test_second_page_returns_correct_posts(self): 35 | posts = [PostFactory(status='published') for _ in range(4)] 36 | response = self.client.get('/blog/?page=2') 37 | self.assertEqual(list(response.context['posts']), [posts[0]]) 38 | 39 | def test_returns_last_page_if_page_is_out_of_range(self): 40 | posts = [PostFactory(status='published') for _ in range(4)] 41 | response = self.client.get('/blog/?page=999') 42 | self.assertEqual(list(response.context['posts']), [posts[0]]) 43 | 44 | def test_bad_tag_raises_404_error(self): 45 | response = self.client.get('/blog/tag/test/') 46 | self.assertEqual(response.status_code, 404) 47 | 48 | def test_if_tag_is_provided_filter_post_by_it(self): 49 | posts = [PostFactory(status='published') for _ in range(4)] 50 | posts[0].tags.add('test') 51 | posts[2].tags.add('test') 52 | response = self.client.get('/blog/tag/test/') 53 | self.assertContains(response, 'Posts tagged with "test"') 54 | self.assertEqual(list(response.context['posts']), [posts[2], posts[0]]) 55 | self.assertEqual(response.context['tag'], Tag.objects.get(name='test')) 56 | 57 | 58 | class PostDetailTest(TestCase): 59 | 60 | def setUp(self): 61 | self.post = PostFactory(status='published') 62 | self.response = self.client.get(self.post.get_absolute_url()) 63 | 64 | def test_post_detail_renders_detail_template(self): 65 | self.assertTemplateUsed(self.response, 'blog/post/detail.html') 66 | 67 | def test_response_contains_post_title(self): 68 | self.assertContains(self.response, self.post.title) 69 | 70 | def test_unknown_post_returns_404(self): 71 | response = self.client.get('/blog/2000/01/02/hello-world/') 72 | self.assertEqual(response.status_code, 404) 73 | 74 | def test_no_comments(self): 75 | self.assertEqual(self.response.context['post'], self.post) 76 | self.assertEqual(list(self.response.context['comments']), []) 77 | self.assertEqual(self.response.context['new_comment'], None) 78 | 79 | def test_incorrect_post(self): 80 | bad_response = self.client.post(self.post.get_absolute_url(), {}) 81 | self.assertContains(bad_response, 'This field is required') 82 | self.assertEqual(self.response.context['new_comment'], None) 83 | 84 | def test_correct_post(self): 85 | good_response = self.client.post(self.post.get_absolute_url(), { 86 | 'name': 'user', 87 | 'email': 'user@example.com', 88 | 'body': 'Sample comment body' 89 | }) 90 | self.assertContains(good_response, 'Sample comment body') 91 | self.assertContains(good_response, 'Your comment has been added.') 92 | self.assertEqual(good_response.context['new_comment'], Comment.objects.get(post=self.post)) 93 | 94 | def test_similar_posts(self): 95 | PostFactory(status='published') 96 | post2 = PostFactory(status='published') 97 | post3 = PostFactory(status='published') 98 | post4 = PostFactory(status='published') 99 | 100 | post4.tags.add('tag0', 'tag1', 'tag2') 101 | post3.tags.add('tag0', 'tag1') 102 | post2.tags.add('tag0') 103 | 104 | response = self.client.get(post4.get_absolute_url()) 105 | 106 | self.assertEqual(list(response.context['similar_posts']), [post3, post2]) 107 | 108 | 109 | class PostShareTest(TestCase): 110 | 111 | def setUp(self): 112 | self.post = PostFactory(status='published') 113 | self.response = self.client.get('/blog/%s/share/' % self.post.id) 114 | 115 | def sample_post_share(self): 116 | return self.client.post('/blog/%s/share/' % self.post.id, data={ 117 | 'name': 'user', 118 | 'sender': 'from@example.com', 119 | 'recipient': 'to@example.com', 120 | 'comments': 'sample comments' 121 | }) 122 | 123 | def test_post_share_renders_share_template(self): 124 | self.assertTemplateUsed(self.response, 'blog/post/share.html') 125 | 126 | def test_response_contains_post_title(self): 127 | self.assertContains(self.response, self.post.title) 128 | 129 | def test_unknown_post_returns_404(self): 130 | response = self.client.get('/blog/999/share/') 131 | self.assertEqual(response.status_code, 404) 132 | 133 | def test_get_method_does_not_send_email(self): 134 | self.assertEqual(len(mail.outbox), 0) 135 | 136 | def test_get_method_returns_form(self): 137 | self.assertContains(self.response, 'form') 138 | 139 | def test_post_method_send_email(self): 140 | self.sample_post_share() 141 | subject = 'user (from@example.com) recommends you reading "%s"' % self.post.title 142 | self.assertEqual(len(mail.outbox), 1) 143 | self.assertEqual(mail.outbox[0].subject, subject) 144 | 145 | def test_post_method_send_email_with_correct_url(self): 146 | self.sample_post_share() 147 | self.assertIn(self.post.get_absolute_url(), mail.outbox[0].body) 148 | 149 | def test_post_method_returns_success_message(self): 150 | post_response = self.sample_post_share() 151 | self.assertContains(post_response, '"%s" was successfully sent.' % self.post.title) 152 | 153 | def test_post_with_invalid_form_returns_form(self): 154 | bad_post_response = self.client.post('/blog/%s/share/' % self.post.id) 155 | self.assertContains(bad_post_response, 'form') 156 | -------------------------------------------------------------------------------- /mysite/functional_tests/test_admin.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # pylint: disable=missing-docstring 3 | 4 | """Admin page tests""" 5 | 6 | from selenium import webdriver 7 | from selenium.webdriver.support.ui import Select 8 | 9 | from django.contrib.auth import get_user_model 10 | from django.contrib.staticfiles.testing import StaticLiveServerTestCase 11 | 12 | from blog.factories import PostFactory, CommentFactory 13 | 14 | 15 | def login(browser, username, password): 16 | """Login given user into django admin 17 | 18 | Args: 19 | browser: Browser instance 20 | username (str) 21 | password (str) 22 | """ 23 | login_form = browser.find_element_by_id('login-form') 24 | login_form.find_element_by_name('username').send_keys(username) 25 | login_form.find_element_by_name('password').send_keys(password) 26 | login_form.find_element_by_css_selector('.submit-row input').click() 27 | 28 | 29 | class TestModelAdmin(StaticLiveServerTestCase): 30 | 31 | def setUp(self): 32 | self.browser = webdriver.Firefox() 33 | self.admin_user = get_user_model().objects.create_superuser( 34 | username='admin', 35 | email='admin@example.com', 36 | password='password' 37 | ) 38 | 39 | def tearDown(self): 40 | self.browser.quit() 41 | 42 | def search_model_by(self, text): 43 | search_field = self.browser.find_element_by_id('searchbar') 44 | search_button = self.browser.find_element_by_css_selector('#changelist-search input[type="submit"]') 45 | 46 | search_field.clear() 47 | search_field.send_keys(text) 48 | search_button.click() 49 | 50 | return self.browser.find_elements_by_css_selector('#result_list [class^="row"]') 51 | 52 | 53 | class TestPostAdmin(TestModelAdmin): 54 | 55 | def test_displayed_list(self): 56 | # We have two posts 57 | post1 = PostFactory(author=self.admin_user) 58 | post2 = PostFactory(author=self.admin_user) 59 | 60 | # Admin opens admin panel 61 | self.browser.get(self.live_server_url + '/admin/') 62 | 63 | # He checks page title to be sure he is in the right place 64 | self.assertEqual(self.browser.title, 'Log in | Django site admin') 65 | 66 | # He logs in 67 | login(self.browser, 'admin', 'password') 68 | 69 | # He sees link to Posts 70 | posts_link = self.browser.find_element_by_link_text('Posts') 71 | self.assertEqual(posts_link.get_attribute('href'), self.live_server_url + '/admin/blog/post/') 72 | 73 | # He clicks on Posts link and see table of posts with columns: title, slug, author, publish and status 74 | posts_link.click() 75 | self.assertEqual(self.browser.find_element_by_css_selector('.column-title a').text, 'TITLE') 76 | self.assertEqual(self.browser.find_element_by_css_selector('.column-slug a').text, 'SLUG') 77 | self.assertEqual(self.browser.find_element_by_css_selector('.column-author a').text, 'AUTHOR') 78 | self.assertEqual(self.browser.find_element_by_css_selector('.column-publish .text a').text, 'PUBLISH') 79 | self.assertEqual(self.browser.find_element_by_css_selector('.column-status .text a').text, 'STATUS') 80 | 81 | # He can filter by status, created date and publish date 82 | filter_div = self.browser.find_element_by_id('changelist-filter') 83 | filter_options = filter_div.find_elements_by_tag_name('h3') 84 | self.assertEqual(filter_options[0].text, 'By status') 85 | self.assertEqual(filter_options[1].text, 'By created') 86 | self.assertEqual(filter_options[2].text, 'By publish') 87 | 88 | # He can search by post title and body 89 | self.assertEqual(len(self.search_model_by('')), 2) 90 | self.assertEqual(len(self.search_model_by(post1.title)), 1) 91 | self.assertEqual(len(self.search_model_by(post2.title)), 1) 92 | self.assertEqual(len(self.search_model_by('Unknown Post')), 0) 93 | 94 | # He can see the date hierarchy links by publish date 95 | self.browser.find_element_by_class_name('xfull') 96 | 97 | # Posts sorted by status and than by publish date 98 | self.search_model_by('') 99 | self.assertEqual(self.browser.find_element_by_css_selector('th:last-child span').text, '1') 100 | self.assertEqual(self.browser.find_element_by_css_selector('th:nth-child(5) span').text, '2') 101 | 102 | # He start a new post 103 | self.browser.find_element_by_css_selector('.addlink').click() 104 | 105 | # He types in post title 106 | self.browser.find_element_by_id('id_title').send_keys('Hello World') 107 | 108 | # He sees that slug field auto-updates 109 | self.assertEqual(self.browser.find_element_by_id('id_slug').get_attribute('value'), 'hello-world') 110 | 111 | # He click at the author lookup button 112 | self.browser.find_element_by_id('lookup_id_author').click() 113 | self.browser.switch_to.window(self.browser.window_handles[1]) 114 | 115 | # He choose author 116 | self.browser.find_element_by_css_selector('.row1 a').click() 117 | 118 | # He sees that author correctly selected 119 | self.browser.switch_to.window(self.browser.window_handles[0]) 120 | self.assertEqual(self.browser.find_element_by_id('id_author').get_attribute('value'), str(self.admin_user.id)) 121 | 122 | # He types in the post body 123 | self.browser.find_element_by_id('id_body').send_keys('Sample post body') 124 | 125 | # He sees publish section 126 | self.browser.find_element_by_id('id_publish_0') 127 | 128 | # He switch post status to Published 129 | select = Select(self.browser.find_element_by_id('id_status')) 130 | select.select_by_visible_text('Published') 131 | 132 | # Saves the post 133 | self.browser.find_element_by_css_selector('.submit-row .default').click() 134 | 135 | # And he sees a new post in the list 136 | self.assertEqual(len(self.search_model_by('')), 3) 137 | 138 | 139 | class TestCommentAdmin(TestModelAdmin): 140 | 141 | def test_displayed_comment(self): 142 | # We have two comments 143 | comment1 = CommentFactory() 144 | comment2 = CommentFactory() 145 | 146 | # Admin opens admin panel 147 | self.browser.get(self.live_server_url + '/admin/') 148 | 149 | # Log in user 150 | login(self.browser, 'admin', 'password') 151 | 152 | # He sees link to Comments 153 | comments_link = self.browser.find_element_by_link_text('Comments') 154 | self.assertEqual(comments_link.get_attribute('href'), self.live_server_url + '/admin/blog/comment/') 155 | 156 | # He clicks on Comments and see table of comments with columns: name, email, post, created, updated and active 157 | comments_link.click() 158 | self.assertEqual(self.browser.find_element_by_css_selector('.column-name a').text, 'NAME') 159 | self.assertEqual(self.browser.find_element_by_css_selector('.column-email a').text, 'EMAIL') 160 | self.assertEqual(self.browser.find_element_by_css_selector('.column-post a').text, 'POST') 161 | self.assertEqual(self.browser.find_element_by_css_selector('.column-created .text a').text, 'CREATED') 162 | self.assertEqual(self.browser.find_element_by_css_selector('.column-updated .text a').text, 'UPDATED') 163 | self.assertEqual(self.browser.find_element_by_css_selector('.column-active a').text, 'ACTIVE') 164 | 165 | # He can filter by created, updated and active 166 | filter_div = self.browser.find_element_by_id('changelist-filter') 167 | filter_options = filter_div.find_elements_by_tag_name('h3') 168 | self.assertEqual(filter_options[0].text, 'By active') 169 | self.assertEqual(filter_options[1].text, 'By created') 170 | self.assertEqual(filter_options[2].text, 'By updated') 171 | 172 | # He can search by name, email and body 173 | self.assertEqual(len(self.search_model_by('')), 2) # Total comments 174 | 175 | for field in ('name', 'email', 'body'): 176 | self.assertEqual(len(self.search_model_by(getattr(comment1, field))), 1) 177 | self.assertEqual(len(self.search_model_by(getattr(comment2, field))), 1) 178 | self.assertEqual(len(self.search_model_by('Unknown Post')), 0) 179 | 180 | # He starts a new comment 181 | self.browser.find_element_by_css_selector('.addlink').click() 182 | 183 | # He choose the post 184 | select = Select(self.browser.find_element_by_id('id_post')) 185 | select.select_by_visible_text(comment1.post.title) 186 | 187 | # He types in a name 188 | self.browser.find_element_by_id('id_name').send_keys('comment_author') 189 | 190 | # He types in an email 191 | self.browser.find_element_by_id('id_email').send_keys('comment_author@example.com') 192 | 193 | # He types in a comment body 194 | self.browser.find_element_by_id('id_body').send_keys('Sample comment body') 195 | 196 | # He sees an 'Active' checkbox 197 | self.browser.find_element_by_id('id_active') 198 | 199 | # He saves the comment 200 | self.browser.find_element_by_css_selector('.submit-row .default').click() 201 | 202 | # And sees a new comment in the list 203 | self.assertEqual(len(self.search_model_by('')), 3) 204 | --------------------------------------------------------------------------------