Chapter 16: Full-Text Search (v0.16)
This commit is contained in:
parent
3096f0042e
commit
87ce9d2ca0
|
@ -8,6 +8,7 @@ from flask_login import LoginManager
|
|||
from flask_mail import Mail
|
||||
from flask_moment import Moment
|
||||
from flask_babel import Babel, lazy_gettext as _l
|
||||
from elasticsearch import Elasticsearch
|
||||
from config import Config
|
||||
|
||||
|
||||
|
@ -35,6 +36,8 @@ def create_app(config_class=Config):
|
|||
mail.init_app(app)
|
||||
moment.init_app(app)
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
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)
|
||||
|
|
|
@ -31,3 +31,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 'meta' not in kwargs:
|
||||
kwargs['meta'] = {'csrf': False}
|
||||
super(SearchForm, self).__init__(*args, **kwargs)
|
||||
|
|
|
@ -5,7 +5,7 @@ from flask_login import current_user, login_required
|
|||
from flask_babel import _, get_locale
|
||||
from langdetect import detect, LangDetectException
|
||||
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.translate import translate
|
||||
from app.main import bp
|
||||
|
@ -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())
|
||||
|
||||
|
||||
|
@ -145,3 +146,19 @@ def translate_text():
|
|||
return {'text': translate(data['text'],
|
||||
data['source_language'],
|
||||
data['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)
|
||||
|
|
|
@ -6,6 +6,50 @@ from flask_login import UserMixin
|
|||
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(
|
||||
|
@ -87,7 +131,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)
|
||||
|
|
|
@ -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, id=model.id, document=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,
|
||||
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']
|
|
@ -25,6 +25,13 @@
|
|||
<li class="nav-item">
|
||||
<a class="nav-link" aria-current="page" href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a>
|
||||
</li>
|
||||
{% 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>
|
||||
<ul class="navbar-nav mb-2 mb-lg-0">
|
||||
{% if current_user.is_anonymous %}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ _('Search Results') }}</h1>
|
||||
{% for post in posts %}
|
||||
{% include '_post.html' %}
|
||||
{% endfor %}
|
||||
<nav aria-label="Post navigation">
|
||||
<ul class="pagination">
|
||||
<li class="page-item{% if not prev_url %} disabled{% endif %}">
|
||||
<a class="page-link" href="#">
|
||||
<span aria-hidden="true">←</span> {{ _('Newer posts') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item{% if not next_url %} disabled{% endif %}">
|
||||
<a class="page-link" href="#">
|
||||
{{ _('Older posts') }} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock %}
|
|
@ -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"
|
||||
|
@ -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."
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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."
|
||||
|
@ -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."
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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!"
|
||||
|
||||
|
|
|
@ -17,4 +17,5 @@ class Config(object):
|
|||
ADMINS = ['your-email@example.com']
|
||||
LANGUAGES = ['en', 'es']
|
||||
MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY')
|
||||
ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')
|
||||
POSTS_PER_PAGE = 25
|
||||
|
|
|
@ -8,6 +8,8 @@ certifi==2022.12.7
|
|||
charset-normalizer==3.1.0
|
||||
click==8.1.3
|
||||
dnspython==2.3.0
|
||||
elastic-transport==8.4.0
|
||||
elasticsearch==8.7.0
|
||||
email-validator==2.0.0.post2
|
||||
Flask==2.3.2
|
||||
flask-babel==3.1.0
|
||||
|
|
Loading…
Reference in New Issue