├── .env ├── .gitattributes ├── .gitignore ├── LICENSE.md ├── README.md ├── _config.yml ├── blog ├── __init__.py ├── admin.py ├── apiviews.py ├── apps.py ├── constants.py ├── models.py ├── search.py ├── serializers.py ├── signals.py ├── templates │ ├── base.html │ ├── contact.html │ ├── index.html │ ├── no-results.html │ └── post.html ├── tests.py ├── urls.py ├── util.py └── views.py ├── manage.py └── pyblog ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py /.env: -------------------------------------------------------------------------------- 1 | DEBUG=True 2 | SECRET_KEY = '#x8140s7$io7eu(byz)nf)demokey%x0(w*nadb_b_a4+*rz0s' 3 | DATABASE_NAME = 'py-blog' 4 | DATABASE_USER = '' 5 | DATABASE_PASSWORD = '' 6 | HOST_ENDPOINT = '127.0.0.1' 7 | 8 | ADMIN_USERNAME = 'adesh.nalpet' 9 | ADMIN_PASSWORD = 'pyblog' -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.css linguist-detectable=false 2 | *.html linguist-detectable=false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | migrations/ 3 | __pycache__ 4 | db.sqlite3 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) django-pyblog 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Pyblog 🚁 2 | Django blog - ElasticSearch + MySQL + Redis 🚀 3 | 4 | #### Why? 5 | - Most of us use wordpress 🥱 for blogs (so do I: https://pyblog.xyz). The search operation in wordpress takes forever, because it's a simple `column like '%%` 🤕 6 | - To perform faster search operations on tags, categories and other key-words. While it can be achieved with Relational and other NoSQL data stores, Elastic Search is the best when it comes to `SEARCH` 😎 7 | 8 | #### Installation ElasticSearch (MacOS) 9 | - Tap the Elastic Homebrew repository: `brew tap elastic/tap` 10 | - `brew install elastic/tap/elasticsearch-full` 11 | - `pip install elasticsearch-dsl` 12 | 13 | #### Start the Server 14 | - `cd /usr/local/var/homebrew/linked/elasticsearch-full/bin` 15 | - `./elasticsearch` 16 | To start ES from GUI, follow: https://opensource.com/article/19/7/installing-elasticsearch-macos 17 | 18 | #### Test ES Set-up 19 | - `curl -XGET http://localhost:9200` 20 | Expected Response (Similar): 21 | ``` 22 | { 23 | "name": "PP-C02Z66CALVCG.local", 24 | "cluster_name": "elasticsearch_adesh.nalpet", 25 | "cluster_uuid": "E_543bFmSUqO7dXwzjE1WQ", 26 | "version": { 27 | "number": "7.8.1", 28 | "build_flavor": "default", 29 | "build_type": "tar", 30 | "build_hash": "b5ca9c58fb664ca8bf9e4057fc229b3396bf3a89", 31 | "build_date": "2020-07-21T16:40:44.668009Z", 32 | "build_snapshot": false, 33 | "lucene_version": "8.5.1", 34 | "minimum_wire_compatibility_version": "6.8.0", 35 | "minimum_index_compatibility_version": "6.0.0-beta1" 36 | }, 37 | "tagline": "You Know, for Search" 38 | } 39 | ``` 40 | 41 | #### Installation Redis (MacOS) 42 | - `brew install redis` 43 | - `brew services start redis` or `redis-server /usr/local/etc/redis.conf` 44 | - `pip install django-redis` 45 | 46 | #### Test Redis Set-up 47 | - `redis-cli ping` 48 | 49 | For details on how to set-up a django project with best practices: https://pyblog.xyz/django-initial-setup/ 50 | 51 | #### Settings 52 | - Update ES and Redis - host and port in `settings.py` 53 | ``` 54 | ELASTICSEARCH_DSL = { 55 | 'default': { 56 | 'hosts': 'localhost:9200' 57 | }, 58 | } 59 | ``` 60 | ``` 61 | CACHES = { 62 | "default": { 63 | "BACKEND": "django_redis.cache.RedisCache", 64 | "LOCATION": "redis://127.0.0.1:6379/1", 65 | "OPTIONS": { 66 | "CLIENT_CLASS": "django_redis.client.DefaultClient" 67 | }, 68 | "KEY_PREFIX": "pyblog" 69 | } 70 | } 71 | ``` 72 | 73 | #### Updating the Index 74 | - bulk indexing: `search.bulk_indexing` 75 | - On every `post_save`: `signals.py` 76 | More details on Django Signals: https://pyblog.xyz/events-using-django-signals/ 77 | 78 | #### Template Used 79 | [Blog Home](https://startbootstrap.com/templates/blog-home/) is a basic blog homepage HTML starter template for [Bootstrap](https://getbootstrap.com/) created by [Start Bootstrap](https://startbootstrap.com/). 80 | 81 | **[View Live Preview](https://startbootstrap.github.io/startbootstrap-blog-home/)** 82 | 83 | ###### Note: The project is obviously over-engineered, it's meant to give an example on how versatile Django can be 😋 84 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /blog/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'blog.apps.BlogConfig' -------------------------------------------------------------------------------- /blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Post 4 | 5 | admin.site.register(Post) 6 | -------------------------------------------------------------------------------- /blog/apiviews.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addu390/django-pyblog/b4c1c3525d185473af6556d1bc8898b25c2e05d1/blog/apiviews.py -------------------------------------------------------------------------------- /blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | name = 'blog' 6 | 7 | def ready(self): 8 | import blog.signals 9 | -------------------------------------------------------------------------------- /blog/constants.py: -------------------------------------------------------------------------------- 1 | PAGE_LIMIT = 1 2 | POST = 'post' 3 | POSTS = 'posts' 4 | CATEGORY = 'category' 5 | -------------------------------------------------------------------------------- /blog/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | import uuid 3 | from .search import PostIndex 4 | from django.contrib.auth.models import User 5 | 6 | 7 | class BaseModel(models.Model): 8 | created_at = models.DateTimeField(auto_now=True) 9 | updated_at = models.DateTimeField(auto_now=True) 10 | 11 | class Meta: 12 | abstract = True 13 | 14 | 15 | class Post(BaseModel): 16 | categories = ( 17 | (1, "Web Development"), 18 | (2, "Java Programming"), 19 | (3, "Python Django"), 20 | (4, "Uncategorized"), 21 | ) 22 | 23 | author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='post', null=True) 24 | post_id = models.CharField(max_length=255, null=False, default=uuid.uuid4().__str__()) 25 | is_active = models.BooleanField(default=True) 26 | category = models.IntegerField(choices=categories, default=4) 27 | description = models.TextField(null=True) 28 | content = models.TextField(null=True) 29 | title = models.TextField(null=True) 30 | 31 | class Meta: 32 | db_table = "post" 33 | indexes = [ 34 | models.Index(fields=['updated_at'], name='post_created_at_idx'), 35 | ] 36 | unique_together = [['post_id']] 37 | 38 | def indexing(self): 39 | obj = PostIndex( 40 | meta={'id': self.id}, 41 | author=self.author.username, 42 | post_id=self.post_id, 43 | is_active=self.is_active, 44 | title=self.title, 45 | category=self.category, 46 | description=self.description, 47 | created_at=self.created_at, 48 | updated_at=self.updated_at, 49 | ) 50 | obj.save() 51 | return obj.to_dict(include_meta=True) 52 | 53 | -------------------------------------------------------------------------------- /blog/search.py: -------------------------------------------------------------------------------- 1 | from elasticsearch_dsl.connections import connections 2 | from elasticsearch_dsl import Document, Text, Date, Search, Boolean, Integer 3 | from elasticsearch.helpers import bulk 4 | from elasticsearch import Elasticsearch 5 | from . import models 6 | 7 | connections.create_connection() 8 | 9 | 10 | class PostIndex(Document): 11 | author = Text() 12 | post_id = Text() 13 | is_active = Boolean() 14 | title = Text() 15 | category = Text() 16 | description = Text() 17 | created_at = Date() 18 | updated_at = Date() 19 | 20 | class Index: 21 | name = 'post-index' 22 | 23 | 24 | def bulk_indexing(): 25 | PostIndex.init() 26 | es = Elasticsearch() 27 | bulk(client=es, actions=(b.indexing() for b in models.Post.objects.all().iterator())) 28 | 29 | 30 | def search_match(description): 31 | s = Search().filter('match', description=description)[:5] 32 | response = s.execute() 33 | return response 34 | 35 | 36 | def search_term(category): 37 | s = Search().filter('term', category=category)[:5] 38 | response = s.execute() 39 | return response 40 | -------------------------------------------------------------------------------- /blog/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from .models import Post 3 | 4 | 5 | class PostSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = Post 8 | fields = '__all__' 9 | -------------------------------------------------------------------------------- /blog/signals.py: -------------------------------------------------------------------------------- 1 | from .models import Post 2 | from django.db.models.signals import post_save 3 | from django.dispatch import receiver 4 | 5 | 6 | @receiver(post_save, sender=Post) 7 | def index_post(sender, instance, **kwargs): 8 | instance.indexing() 9 | -------------------------------------------------------------------------------- /blog/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Blog Home - PyBlog 11 | 12 | 13 | 18 | 19 | 20 | 21 | 48 | 49 |
50 |
51 | {% block content %}{% endblock content %} 52 | 53 |
54 |
55 |
Search
56 |
57 |
58 |
59 | 60 | 61 | 62 | 63 |
64 |
65 |
66 |
67 | 68 |
69 |
Categories
70 |
71 |
72 |
73 |
    74 | {% for category in categories %} 75 |
  • 76 | {{ category.1 }} 77 |
  • 78 | {% endfor %} 79 |
80 |
81 |
82 |
83 |
84 | 85 |
86 |
Side Widget
87 |
88 | You can put anything you want inside of these side widgets. 89 | They are easy to use, and feature the new Bootstrap 4 card containers! 90 |
91 |
92 |
93 |
94 |
95 | 96 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /blog/templates/contact.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 |
3 |
4 |
5 |
6 |

Contact Us

7 |

Do you have any questions? Please do not hesitate to contact us directly.

8 |
9 | 10 |
11 | 12 |
13 |
14 | 15 |
16 | 17 |
18 | 19 |
20 |
21 | 22 |
23 | 24 |
25 | 26 |
27 |
28 | 29 |
30 |
31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 | {% endblock content %} 39 | -------------------------------------------------------------------------------- /blog/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 |
3 |

4 | 5 |

6 | {% for post in posts %} 7 |
8 | Card image cap 9 |
10 |

{{ post.title }}

11 |

{{ post.description }}

12 | Read More → 13 |
14 | 15 |
16 | {% endfor %} 17 | 18 | {% if posts.count > 0 %} 19 | 32 | {% endif %} 33 |
34 | {% endblock content %} 35 | -------------------------------------------------------------------------------- /blog/templates/no-results.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 | 3 |
4 |

No search results found

5 |
6 | 7 | {% endblock content %} -------------------------------------------------------------------------------- /blog/templates/post.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 |
3 |

{{ post.title }}

4 |

5 | by 6 | {{ post.author }} 7 |

8 |
9 |

Posted on {{ post.updated_at }}

10 | 11 |
12 | 13 | 14 |
15 |

{{ post.content }}

16 |
17 |
18 | {% endblock content %} 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path("", views.home, name="home"), 6 | path('posts//', views.index, name='posts'), 7 | path('posts//', views.post, name='post'), 8 | path('search/', views.search, name='search'), 9 | path('category/', views.search_category, name='category'), 10 | path('contact/', views.contact, name='contact'), 11 | ] -------------------------------------------------------------------------------- /blog/util.py: -------------------------------------------------------------------------------- 1 | from .constants import PAGE_LIMIT 2 | 3 | 4 | def get_page_details(page_id, count): 5 | page_details = {'remaining': count - (PAGE_LIMIT * page_id)} 6 | if page_id == 1: 7 | page_details['previous'] = 0 8 | page_details['next'] = 2 9 | 10 | elif page_id > 1: 11 | page_details['previous'] = page_id - 1 12 | page_details['next'] = page_id + 1 13 | 14 | return page_details -------------------------------------------------------------------------------- /blog/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, get_object_or_404, redirect 2 | 3 | from .constants import PAGE_LIMIT, POST, POSTS 4 | from .models import Post 5 | from django.conf import settings 6 | from django.views.decorators.cache import cache_page 7 | from django.core.cache.backends.base import DEFAULT_TIMEOUT 8 | from .search import search_match, search_term 9 | from .util import get_page_details 10 | 11 | CACHE_TTL = getattr(settings, 'CACHE_TTL', DEFAULT_TIMEOUT) 12 | CATEGORIES = {'categories': [list(item) for item in Post.categories]} 13 | 14 | 15 | @cache_page(CACHE_TTL) 16 | def index(request, page_id): 17 | offset = (PAGE_LIMIT * page_id) - PAGE_LIMIT 18 | posts = Post.objects.order_by('-updated_at')[offset:offset + PAGE_LIMIT] 19 | posts_count = Post.objects.all().count() 20 | context = { 21 | POSTS: posts, 22 | } 23 | context.update(CATEGORIES) 24 | context.update(get_page_details(page_id, posts_count)) 25 | return render(request, 'index.html', context=context) 26 | 27 | 28 | @cache_page(CACHE_TTL) 29 | def post(request, post_id): 30 | post_detail = get_object_or_404(Post, post_id=post_id) 31 | context = { 32 | POST: post_detail 33 | } 34 | context.update(CATEGORIES) 35 | print(context) 36 | return render(request, 'post.html', context=context) 37 | 38 | 39 | def contact(request): 40 | context = {} 41 | return render(request, 'contact.html', context=context) 42 | 43 | 44 | def search(request): 45 | match_id = request.GET.get('q') 46 | posts = search_match(match_id) 47 | context = { 48 | POSTS: posts 49 | } 50 | context.update(CATEGORIES) 51 | if posts: 52 | return render(request, 'index.html', context=context) 53 | else: 54 | return render(request, 'no-results.html', context=context) 55 | 56 | 57 | def search_category(request): 58 | category_id = request.GET.get('category') 59 | posts = search_term(category_id) 60 | context = { 61 | POSTS: posts 62 | } 63 | context.update(CATEGORIES) 64 | if posts: 65 | return render(request, 'index.html', context=context) 66 | else: 67 | return render(request, 'no-results.html', context=context) 68 | 69 | 70 | def home(request): 71 | return redirect("/posts/1") 72 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pyblog.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /pyblog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addu390/django-pyblog/b4c1c3525d185473af6556d1bc8898b25c2e05d1/pyblog/__init__.py -------------------------------------------------------------------------------- /pyblog/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for pyblog project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pyblog.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /pyblog/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from decouple import config 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | SECRET_KEY = '6slqsqoe$4o*yme-nmcy@11!5$hj98!e78&gxim0*%ib-y5bw^' 7 | 8 | DEBUG = config('DEBUG', default=False, cast=bool) 9 | 10 | ALLOWED_HOSTS = ['*'] 11 | 12 | INSTALLED_APPS = [ 13 | 'django.contrib.admin', 14 | 'django.contrib.auth', 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.sessions', 17 | 'django.contrib.messages', 18 | 'django.contrib.staticfiles', 19 | 'blog' 20 | ] 21 | 22 | MIDDLEWARE = [ 23 | 'django.middleware.security.SecurityMiddleware', 24 | 'django.contrib.sessions.middleware.SessionMiddleware', 25 | 'django.middleware.common.CommonMiddleware', 26 | 'django.middleware.csrf.CsrfViewMiddleware', 27 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 28 | 'django.contrib.messages.middleware.MessageMiddleware', 29 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 30 | ] 31 | 32 | ROOT_URLCONF = 'pyblog.urls' 33 | 34 | TEMPLATES = [ 35 | { 36 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 37 | 'DIRS': [], 38 | 'APP_DIRS': True, 39 | 'OPTIONS': { 40 | 'context_processors': [ 41 | 'django.template.context_processors.debug', 42 | 'django.template.context_processors.request', 43 | 'django.contrib.auth.context_processors.auth', 44 | 'django.contrib.messages.context_processors.messages', 45 | ], 46 | }, 47 | }, 48 | ] 49 | 50 | WSGI_APPLICATION = 'pyblog.wsgi.application' 51 | 52 | 53 | DATABASES = { 54 | 'default': { 55 | 'ENGINE': 'django.db.backends.mysql', 56 | 'NAME': config('DATABASE_NAME'), 57 | 'USER': config('DATABASE_USER'), 58 | 'PASSWORD': config('DATABASE_PASSWORD'), 59 | 'HOST': config('HOST_ENDPOINT'), 60 | 'PORT': '3306', 61 | } 62 | } 63 | 64 | ELASTICSEARCH_DSL = { 65 | 'default': { 66 | 'hosts': 'localhost:9200' 67 | }, 68 | } 69 | 70 | CACHES = { 71 | "default": { 72 | "BACKEND": "django_redis.cache.RedisCache", 73 | "LOCATION": "redis://127.0.0.1:6379/1", 74 | "OPTIONS": { 75 | "CLIENT_CLASS": "django_redis.client.DefaultClient" 76 | }, 77 | "KEY_PREFIX": "pyblog" 78 | } 79 | } 80 | 81 | CACHE_TTL = 60 * 1 82 | 83 | AUTH_PASSWORD_VALIDATORS = [ 84 | { 85 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 86 | }, 87 | { 88 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 89 | }, 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 95 | }, 96 | ] 97 | 98 | 99 | LANGUAGE_CODE = 'en-us' 100 | 101 | TIME_ZONE = 'UTC' 102 | 103 | USE_I18N = True 104 | 105 | USE_L10N = True 106 | 107 | USE_TZ = True 108 | 109 | 110 | STATIC_URL = '/static/' 111 | -------------------------------------------------------------------------------- /pyblog/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | path('', include('blog.urls')), 7 | ] 8 | -------------------------------------------------------------------------------- /pyblog/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for pyblog 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/3.0/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', 'pyblog.settings') 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------