Chapter 16: Full-Text Search (v0.16)

This commit is contained in:
Miguel Grinberg 2017-09-20 12:51:53 -07:00
parent f4a401e320
commit 868ad4b410
No known key found for this signature in database
GPG Key ID: 36848B262DF5F06C
11 changed files with 177 additions and 26 deletions

View File

@ -9,6 +9,7 @@ from flask_mail import Mail
from flask_bootstrap import Bootstrap from flask_bootstrap import Bootstrap
from flask_moment import Moment from flask_moment import Moment
from flask_babel import Babel, lazy_gettext as _l from flask_babel import Babel, lazy_gettext as _l
from elasticsearch import Elasticsearch
from config import Config from config import Config
db = SQLAlchemy() db = SQLAlchemy()
@ -33,6 +34,8 @@ def create_app(config_class=Config):
bootstrap.init_app(app) bootstrap.init_app(app)
moment.init_app(app) moment.init_app(app)
babel.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 from app.errors import bp as errors_bp
app.register_blueprint(errors_bp) app.register_blueprint(errors_bp)

View File

@ -31,3 +31,13 @@ class PostForm(FlaskForm):
post = TextAreaField(_l('Say something'), validators=[DataRequired()]) post = TextAreaField(_l('Say something'), validators=[DataRequired()])
submit = SubmitField(_l('Submit')) 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 'meta' not in kwargs:
kwargs['meta'] = {'csrf': False}
super(SearchForm, self).__init__(*args, **kwargs)

View File

@ -5,7 +5,7 @@ from flask_login import current_user, login_required
from flask_babel import _, get_locale from flask_babel import _, get_locale
from langdetect import detect, LangDetectException from langdetect import detect, LangDetectException
from app import db from app import db
from app.main.forms import EditProfileForm, EmptyForm, PostForm from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm
from app.models import User, Post from app.models import User, Post
from app.translate import translate from app.translate import translate
from app.main import bp from app.main import bp
@ -16,6 +16,7 @@ def before_request():
if current_user.is_authenticated: if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow() current_user.last_seen = datetime.utcnow()
db.session.commit() db.session.commit()
g.search_form = SearchForm()
g.locale = str(get_locale()) g.locale = str(get_locale())
@ -144,3 +145,19 @@ def translate_text():
return jsonify({'text': translate(request.form['text'], return jsonify({'text': translate(request.form['text'],
request.form['source_language'], request.form['source_language'],
request.form['dest_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)

View File

@ -6,6 +6,50 @@ from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import jwt import jwt
from app import db, login 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( followers = db.Table(
@ -82,7 +126,8 @@ def load_user(id):
return User.query.get(int(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) id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.String(140)) body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)

27
app/search.py Normal file
View File

@ -0,0 +1,27 @@
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, id=model.id, body=payload)
def remove_from_index(index, model):
if not current_app.elasticsearch:
return
current_app.elasticsearch.delete(index=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,
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']['value']

View File

@ -21,6 +21,13 @@
<li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li> <li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li>
<li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li> <li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li>
</ul> </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"> <ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %} {% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a></li> <li><a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a></li>

22
app/templates/search.html Normal file
View File

@ -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 %}

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: 2017-09-29 23:25-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es\n" "Language: es\n"
@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.5.1\n" "Generated-By: Babel 2.5.1\n"
#: app/__init__.py:17 #: app/__init__.py:18
msgid "Please log in to access this page." msgid "Please log in to access this page."
msgstr "Por favor ingrese para acceder a esta página." msgstr "Por favor ingrese para acceder a esta página."
@ -34,43 +34,43 @@ msgstr "Error el servicio de traducciones ha fallado."
msgid "[Microblog] Reset Your Password" msgid "[Microblog] Reset Your Password"
msgstr "[Microblog] Nueva Contraseña" 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" msgid "Username"
msgstr "Nombre de usuario" 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" msgid "Password"
msgstr "Contraseña" msgstr "Contraseña"
#: app/auth/forms.py:11 #: app/auth/forms.py:12
msgid "Remember Me" msgid "Remember Me"
msgstr "Recordarme" 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" msgid "Sign In"
msgstr "Ingresar" msgstr "Ingresar"
#: app/auth/forms.py:17 app/auth/forms.py:36 #: app/auth/forms.py:18 app/auth/forms.py:37
msgid "Email" msgid "Email"
msgstr "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" msgid "Repeat Password"
msgstr "Repetir Contraseña" 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" msgid "Register"
msgstr "Registrarse" 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." msgid "Please use a different username."
msgstr "Por favor use un nombre de usuario diferente." 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." msgid "Please use a different email address."
msgstr "Por favor use una dirección de email diferente." 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" msgid "Request Password Reset"
msgstr "Pedir una nueva contraseña" msgstr "Pedir una nueva contraseña"
@ -102,37 +102,41 @@ msgstr "Enviar"
msgid "Say something" msgid "Say something"
msgstr "Dí algo" 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!" msgid "Your post is now live!"
msgstr "¡Tu artículo ha sido publicado!" msgstr "¡Tu artículo ha sido publicado!"
#: app/main/routes.py:86 #: app/main/routes.py:87
msgid "Your changes have been saved." msgid "Your changes have been saved."
msgstr "Tus cambios han sido salvados." 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" msgid "Edit Profile"
msgstr "Editar Perfil" 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 #, python-format
msgid "User %(username)s not found." msgid "User %(username)s not found."
msgstr "El usuario %(username)s no ha sido encontrado." msgstr "El usuario %(username)s no ha sido encontrado."
#: app/main/routes.py:103 #: app/main/routes.py:104
msgid "You cannot follow yourself!" msgid "You cannot follow yourself!"
msgstr "¡No te puedes seguir a tí mismo!" msgstr "¡No te puedes seguir a tí mismo!"
#: app/main/routes.py:107 #: app/main/routes.py:108
#, python-format #, python-format
msgid "You are following %(username)s!" msgid "You are following %(username)s!"
msgstr "¡Ahora estás siguiendo a %(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!" msgid "You cannot unfollow yourself!"
msgstr "¡No te puedes dejar de seguir a tí mismo!" msgstr "¡No te puedes dejar de seguir a tí mismo!"
#: app/main/routes.py:123 #: app/main/routes.py:124
#, python-format #, python-format
msgid "You are not following %(username)s." msgid "You are not following %(username)s."
msgstr "No estás siguiendo a %(username)s." msgstr "No estás siguiendo a %(username)s."
@ -158,19 +162,19 @@ msgstr "Inicio"
msgid "Explore" msgid "Explore"
msgstr "Explorar" msgstr "Explorar"
#: app/templates/base.html:26 #: app/templates/base.html:33
msgid "Login" msgid "Login"
msgstr "Ingresar" msgstr "Ingresar"
#: app/templates/base.html:28 #: app/templates/base.html:35
msgid "Profile" msgid "Profile"
msgstr "Perfil" msgstr "Perfil"
#: app/templates/base.html:29 #: app/templates/base.html:36
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: app/templates/base.html:66 #: app/templates/base.html:73
msgid "Error: Could not contact server." msgid "Error: Could not contact server."
msgstr "Error: el servidor no pudo ser contactado." msgstr "Error: el servidor no pudo ser contactado."
@ -187,6 +191,18 @@ msgstr "Artículos siguientes"
msgid "Older posts" msgid "Older posts"
msgstr "Artículos previos" 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 #: app/templates/user.html:8
msgid "User" msgid "User"
msgstr "Usuario" msgstr "Usuario"
@ -256,3 +272,4 @@ msgstr "Ha ocurrido un error inesperado"
#: app/templates/errors/500.html:5 #: app/templates/errors/500.html:5
msgid "The administrator has been notified. Sorry for the inconvenience!" msgid "The administrator has been notified. Sorry for the inconvenience!"
msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!"

View File

@ -18,4 +18,5 @@ class Config(object):
ADMINS = ['your-email@example.com'] ADMINS = ['your-email@example.com']
LANGUAGES = ['en', 'es'] LANGUAGES = ['en', 'es']
MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY')
ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')
POSTS_PER_PAGE = 25 POSTS_PER_PAGE = 25

View File

@ -6,6 +6,7 @@ chardet==4.0.0
click==8.0.1 click==8.0.1
dnspython==2.1.0 dnspython==2.1.0
dominate==2.6.0 dominate==2.6.0
elasticsearch==7.13.3
email-validator==1.1.3 email-validator==1.1.3
Flask==2.0.1 Flask==2.0.1
Flask-Babel==2.0.0 Flask-Babel==2.0.0

View File

@ -9,6 +9,7 @@ from config import Config
class TestConfig(Config): class TestConfig(Config):
TESTING = True TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://' SQLALCHEMY_DATABASE_URI = 'sqlite://'
ELASTICSEARCH_URL = None
class UserModelCase(unittest.TestCase): class UserModelCase(unittest.TestCase):