Skip to content

Commit

Permalink
Chapter 16: Full-Text Search (v0.16)
Browse files Browse the repository at this point in the history
  • Loading branch information
miguelgrinberg committed Oct 4, 2018
1 parent 64ed764 commit 25a2c47
Show file tree
Hide file tree
Showing 11 changed files with 177 additions and 26 deletions.
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_babel import Babel, lazy_gettext as _l
from elasticsearch import Elasticsearch
from config import Config

db = SQLAlchemy()
Expand All @@ -33,6 +34,8 @@ def create_app(config_class=Config):
bootstrap.init_app(app)
moment.init_app(app)
babel.init_app(app)
app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \
if app.config['ELASTICSEARCH_URL'] else None

from app.errors import bp as errors_bp
app.register_blueprint(errors_bp)
Expand Down
10 changes: 10 additions & 0 deletions app/main/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ class PostForm(FlaskForm):
post = TextAreaField(_l('Say something'), validators=[DataRequired()])
submit = SubmitField(_l('Submit'))


class SearchForm(FlaskForm):
q = StringField(_l('Search'), validators=[DataRequired()])

def __init__(self, *args, **kwargs):
if 'formdata' not in kwargs:
kwargs['formdata'] = request.args
if 'csrf_enabled' not in kwargs:
kwargs['csrf_enabled'] = False
super(SearchForm, self).__init__(*args, **kwargs)
18 changes: 17 additions & 1 deletion app/main/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from flask_babel import _, get_locale
from guess_language import guess_language
from app import db
from app.main.forms import EditProfileForm, PostForm
from app.main.forms import EditProfileForm, PostForm, SearchForm
from app.models import User, Post
from app.translate import translate
from app.main import bp
Expand All @@ -16,6 +16,7 @@ def before_request():
if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow()
db.session.commit()
g.search_form = SearchForm()
g.locale = str(get_locale())


Expand Down Expand Up @@ -132,3 +133,18 @@ def translate_text():
request.form['source_language'],
request.form['dest_language'])})


@bp.route('/search')
@login_required
def search():
if not g.search_form.validate():
return redirect(url_for('main.explore'))
page = request.args.get('page', 1, type=int)
posts, total = Post.search(g.search_form.q.data, page,
current_app.config['POSTS_PER_PAGE'])
next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \
if total > page * current_app.config['POSTS_PER_PAGE'] else None
prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \
if page > 1 else None
return render_template('search.html', title=_('Search'), posts=posts,
next_url=next_url, prev_url=prev_url)
47 changes: 46 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,50 @@
from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import db, login
from app.search import add_to_index, remove_from_index, query_index


class SearchableMixin(object):
@classmethod
def search(cls, expression, page, per_page):
ids, total = query_index(cls.__tablename__, expression, page, per_page)
if total == 0:
return cls.query.filter_by(id=0), 0
when = []
for i in range(len(ids)):
when.append((ids[i], i))
return cls.query.filter(cls.id.in_(ids)).order_by(
db.case(when, value=cls.id)), total

@classmethod
def before_commit(cls, session):
session._changes = {
'add': list(session.new),
'update': list(session.dirty),
'delete': list(session.deleted)
}

@classmethod
def after_commit(cls, session):
for obj in session._changes['add']:
if isinstance(obj, SearchableMixin):
add_to_index(obj.__tablename__, obj)
for obj in session._changes['update']:
if isinstance(obj, SearchableMixin):
add_to_index(obj.__tablename__, obj)
for obj in session._changes['delete']:
if isinstance(obj, SearchableMixin):
remove_from_index(obj.__tablename__, obj)
session._changes = None

@classmethod
def reindex(cls):
for obj in cls.query:
add_to_index(cls.__tablename__, obj)


db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit)
db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit)


followers = db.Table(
Expand Down Expand Up @@ -83,7 +127,8 @@ def load_user(id):
return User.query.get(int(id))


class Post(db.Model):
class Post(SearchableMixin, db.Model):
__searchable__ = ['body']
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
Expand Down
28 changes: 28 additions & 0 deletions app/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from flask import current_app


def add_to_index(index, model):
if not current_app.elasticsearch:
return
payload = {}
for field in model.__searchable__:
payload[field] = getattr(model, field)
current_app.elasticsearch.index(index=index, doc_type=index, id=model.id,
body=payload)


def remove_from_index(index, model):
if not current_app.elasticsearch:
return
current_app.elasticsearch.delete(index=index, doc_type=index, id=model.id)


def query_index(index, query, page, per_page):
if not current_app.elasticsearch:
return [], 0
search = current_app.elasticsearch.search(
index=index, doc_type=index,
body={'query': {'multi_match': {'query': query, 'fields': ['*']}},
'from': (page - 1) * per_page, 'size': per_page})
ids = [int(hit['_id']) for hit in search['hits']['hits']]
return ids, search['hits']['total']
7 changes: 7 additions & 0 deletions app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
<li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li>
<li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li>
</ul>
{% if g.search_form %}
<form class="navbar-form navbar-left" method="get" action="{{ url_for('main.search') }}">
<div class="form-group">
{{ g.search_form.q(size=20, class='form-control', placeholder=g.search_form.q.label.text) }}
</div>
</form>
{% endif %}
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a></li>
Expand Down
22 changes: 22 additions & 0 deletions app/templates/search.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends "base.html" %}

{% block app_content %}
<h1>{{ _('Search Results') }}</h1>
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
<nav aria-label="...">
<ul class="pager">
<li class="previous{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}">
<span aria-hidden="true">&larr;</span> {{ _('Previous results') }}
</a>
</li>
<li class="next{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}">
{{ _('Next results') }} <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</nav>
{% endblock %}
65 changes: 41 additions & 24 deletions app/translations/es/LC_MESSAGES/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-11-25 17:17-0800\n"
"POT-Creation-Date: 2017-11-25 18:23-0800\n"
"PO-Revision-Date: 2017-09-29 23:25-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es\n"
Expand All @@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.5.1\n"

#: app/__init__.py:17
#: app/__init__.py:18
msgid "Please log in to access this page."
msgstr "Por favor ingrese para acceder a esta página."

Expand All @@ -34,43 +34,43 @@ msgstr "Error el servicio de traducciones ha fallado."
msgid "[Microblog] Reset Your Password"
msgstr "[Microblog] Nueva Contraseña"

#: app/auth/forms.py:9 app/auth/forms.py:16 app/main/forms.py:10
#: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10
msgid "Username"
msgstr "Nombre de usuario"

#: app/auth/forms.py:10 app/auth/forms.py:18 app/auth/forms.py:41
#: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42
msgid "Password"
msgstr "Contraseña"

#: app/auth/forms.py:11
#: app/auth/forms.py:12
msgid "Remember Me"
msgstr "Recordarme"

#: app/auth/forms.py:12 app/templates/auth/login.html:5
#: app/auth/forms.py:13 app/templates/auth/login.html:5
msgid "Sign In"
msgstr "Ingresar"

#: app/auth/forms.py:17 app/auth/forms.py:36
#: app/auth/forms.py:18 app/auth/forms.py:37
msgid "Email"
msgstr "Email"

#: app/auth/forms.py:20 app/auth/forms.py:43
#: app/auth/forms.py:21 app/auth/forms.py:44
msgid "Repeat Password"
msgstr "Repetir Contraseña"

#: app/auth/forms.py:22 app/templates/auth/register.html:5
#: app/auth/forms.py:23 app/templates/auth/register.html:5
msgid "Register"
msgstr "Registrarse"

#: app/auth/forms.py:27 app/main/forms.py:23
#: app/auth/forms.py:28 app/main/forms.py:23
msgid "Please use a different username."
msgstr "Por favor use un nombre de usuario diferente."

#: app/auth/forms.py:32
#: app/auth/forms.py:33
msgid "Please use a different email address."
msgstr "Por favor use una dirección de email diferente."

#: app/auth/forms.py:37 app/auth/forms.py:45
#: app/auth/forms.py:38 app/auth/forms.py:46
msgid "Request Password Reset"
msgstr "Pedir una nueva contraseña"

Expand Down Expand Up @@ -102,37 +102,41 @@ msgstr "Enviar"
msgid "Say something"
msgstr "Dí algo"

#: app/main/routes.py:35
#: app/main/forms.py:32
msgid "Search"
msgstr "Buscar"

#: app/main/routes.py:36
msgid "Your post is now live!"
msgstr "¡Tu artículo ha sido publicado!"

#: app/main/routes.py:86
#: app/main/routes.py:87
msgid "Your changes have been saved."
msgstr "Tus cambios han sido salvados."

#: app/main/routes.py:91 app/templates/edit_profile.html:5
#: app/main/routes.py:92 app/templates/edit_profile.html:5
msgid "Edit Profile"
msgstr "Editar Perfil"

#: app/main/routes.py:100 app/main/routes.py:116
#: app/main/routes.py:101 app/main/routes.py:117
#, python-format
msgid "User %(username)s not found."
msgstr "El usuario %(username)s no ha sido encontrado."

#: app/main/routes.py:103
#: app/main/routes.py:104
msgid "You cannot follow yourself!"
msgstr "¡No te puedes seguir a tí mismo!"

#: app/main/routes.py:107
#: app/main/routes.py:108
#, python-format
msgid "You are following %(username)s!"
msgstr "¡Ahora estás siguiendo a %(username)s!"

#: app/main/routes.py:119
#: app/main/routes.py:120
msgid "You cannot unfollow yourself!"
msgstr "¡No te puedes dejar de seguir a tí mismo!"

#: app/main/routes.py:123
#: app/main/routes.py:124
#, python-format
msgid "You are not following %(username)s."
msgstr "No estás siguiendo a %(username)s."
Expand All @@ -158,19 +162,19 @@ msgstr "Inicio"
msgid "Explore"
msgstr "Explorar"

#: app/templates/base.html:26
#: app/templates/base.html:33
msgid "Login"
msgstr "Ingresar"

#: app/templates/base.html:28
#: app/templates/base.html:35
msgid "Profile"
msgstr "Perfil"

#: app/templates/base.html:29
#: app/templates/base.html:36
msgid "Logout"
msgstr "Salir"

#: app/templates/base.html:66
#: app/templates/base.html:73
msgid "Error: Could not contact server."
msgstr "Error: el servidor no pudo ser contactado."

Expand All @@ -187,6 +191,18 @@ msgstr "Artículos siguientes"
msgid "Older posts"
msgstr "Artículos previos"

#: app/templates/search.html:4
msgid "Search Results"
msgstr "Resultados de Búsqueda"

#: app/templates/search.html:12
msgid "Previous results"
msgstr "Resultados previos"

#: app/templates/search.html:17
msgid "Next results"
msgstr "Resultados próximos"

#: app/templates/user.html:8
msgid "User"
msgstr "Usuario"
Expand Down Expand Up @@ -256,3 +272,4 @@ msgstr "Ha ocurrido un error inesperado"
#: app/templates/errors/500.html:5
msgid "The administrator has been notified. Sorry for the inconvenience!"
msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!"

1 change: 1 addition & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ class Config(object):
ADMINS = ['[email protected]']
LANGUAGES = ['en', 'es']
MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY')
ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')
POSTS_PER_PAGE = 25
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ certifi==2017.7.27.1
chardet==3.0.4
click==6.7
dominate==2.3.1
elasticsearch==6.1.1
Flask==1.0.2
Flask-Babel==0.11.2
Flask-Bootstrap==3.3.7.1
Expand Down
1 change: 1 addition & 0 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://'
ELASTICSEARCH_URL = None


class UserModelCase(unittest.TestCase):
Expand Down

0 comments on commit 25a2c47

Please sign in to comment.