Merge pull request 'search' (#145) from search into dev

Reviewed-on: https://gitea.planet-casio.com/devs/PCv5/pulls/145
This commit is contained in:
Eragon 2023-09-05 22:50:20 +02:00
commit db065f83f3
11 changed files with 386 additions and 17 deletions

View File

@ -1,13 +1,59 @@
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.fields.datetime import DateField
from wtforms.validators import InputRequired, Optional
from wtforms.fields import StringField, SubmitField, SelectField, SelectMultipleField, DateField
from app.models.forum import Forum
# TODO: compléter le formulaire de recherche avancée
class SearchForm(FlaskForm):
class Meta:
csrf = False
q = StringField('Rechercher', validators=[InputRequired()])
class AdvancedSearchForm(SearchForm):
date = DateField('Date', validators=[Optional()])
def generate_choices():
choices = {'Forum': [
'Actualités',
'Aide et questions',
'Forum des projets',
'Vie communautaire',
], 'Programmes': [
'Jeux',
'Utilitaires',
'Logiciels'
], 'Utilisateurs': [
'Tous'
], 'Tutoriels': [
'Basic',
'C/C++',
'Arduino',
'Python'
], 'Sprites': [
'Personnages',
'Environnements',
'Objets',
'Interfaces'
]}
# Forum reserved for admins and moderators
f = Forum.query.filter_by(url='/admin').first()
if (current_user.is_authenticated and current_user.can_access_forum(f)):
choices['Forum'].append('Administration')
# Forum reserved to members of CreativeCalc
f = Forum.query.filter_by(url='/creativecalc').first()
if (current_user.is_authenticated and current_user.can_access_forum(f)):
choices['Forum'].append('CreativeCalc')
return choices
sortBy = SelectField('Trier',
choices={'Pertinence': ['Pertinence'],
'Date': ['Date croissante',
'Date décroissante'],
'Ordre Alphabétique': [
'Alphabétique croissant',
'Alphabétique décroissant',]},
validators=[Optional()])
date = DateField('Date de publication', validators=[Optional()])
scope = SelectMultipleField('', choices=generate_choices, validators=[Optional()])
submit = SubmitField('Affiner la recherche')

View File

@ -6,7 +6,7 @@ from app.routes.admin import index, groups, account, forums, \
attachments, config, members, polls, login_as
from app.routes.forum import index, topic
from app.routes.polls import vote, delete
from app.routes.posts import edit
from app.routes.posts import edit, redirect
from app.routes.programs import index, submit, program
from app.routes.api import markdown

View File

@ -0,0 +1,26 @@
from app import app
from app.models.comment import Comment
from app.models.thread import Thread
from flask import redirect, url_for
@app.route('/post/<int:postid>', methods=['GET', 'POST'])
def redirect_post(postid):
c = Comment.query.get_or_404(postid)
owner = c.thread.owner_post
if owner.type == 'topic':
# Is a topic
comments = Comment.query.where(
Comment.thread_id == c.thread.id,
Comment.date_created <= c.date_created
).order_by(
Comment.date_created.asc()
).paginate(per_page=Thread.COMMENTS_PER_PAGE, error_out=False)
url = url_for('forum_topic', f=owner.forum, page=(owner, comments.pages), _anchor=str(c.id))
else:
# Is a program
url = url_for('program_view', page=owner.name)
return redirect(url, 301)

View File

@ -1,9 +1,65 @@
from app import app
from app.forms.search import AdvancedSearchForm
from app import app, db
from app.forms.search import AdvancedSearchForm, SearchForm
from app.models.post import Post
from app.models.comment import Comment
from app.models.topic import Topic
from app.models.forum import Forum
from app.models.program import Program
from app.utils.render import render
from sqlalchemy import text, func
from flask import request
from flask_sqlalchemy import Pagination
@app.route('/rechercher')
def search():
form = AdvancedSearchForm()
return render('search.html', form=form)
SEARCH_RESULTS_PER_PAGE = 20
def paginate(data, page, per_page):
# Based on page and per_page info, calculate start and end index of items to keep
start_index = (page - 1) * per_page
end_index = start_index + per_page
# Get the paginated list of items
items = data[start_index:end_index]
# Create Pagination object
return Pagination(None, page, per_page, len(data), items)
def websearch_to_tsquery_multilang(search):
return func.websearch_to_tsquery('french', search).op('||')(func.websearch_to_tsquery('english', search))
def to_tsvector_multilang(text):
return func.to_tsvector('french', text).op('||')(func.to_tsvector('english', text))
@app.route('/rechercher/')
@app.route('/rechercher/<int:page>/')
def search(page=1):
form = AdvancedSearchForm(request.args)
results = list()
if form.validate():
tsquery = websearch_to_tsquery_multilang(form.q.data)
# Topics are sorted first in results
topic_query = db.session.query(Topic).where(
to_tsvector_multilang(Topic.title).bool_op('@@')(tsquery)
).group_by(
Topic.id,
Post.id
)
# Programms follow directly in the results
program_query = db.session.query(Program).where(
to_tsvector_multilang(Program.name).bool_op('@@')(tsquery)
).group_by(
Program.id,
Post.id
)
# Comments are less important than topics and programs
comment_query = db.session.query(Comment).where(
to_tsvector_multilang(Comment.text).bool_op('@@')(tsquery)
).group_by(
Comment.id,
Post.id
)
results = list(topic_query) + list(program_query) + list(comment_query)
results = paginate(results, page, SEARCH_RESULTS_PER_PAGE)
return render('search.html', form=form, results=results)

View File

@ -158,7 +158,7 @@ input[type="submit"]:focus {
left: 50%;
padding: 8px;
position: absolute;
transform: translateY(-100%);
transform: translateY(-200%);
transition: transform 0.3s;
background: var(--links);
color: var(--warn-text);

60
app/static/css/search.css Normal file
View File

@ -0,0 +1,60 @@
.search-page > form {
display: grid;
grid-template-areas: 'search search submit''date sort scope''results results scope';
grid-template-rows: 40% 40% 20%;
grid-template-rows: 5em 5em 100%;
}
.search-page > form input,
.search-page > form select {
width: 100%;
height: 2rem;
}
.search-page > form label {
margin-right: 1em;
}
.search-page > form div.query {
grid-area: search;
display: flex;
align-items: center;
}
.search-page > form div.query label {
margin-right: 1em;
}
.search-page > form div.submit {
grid-area: submit;
display: flex;
align-items: center;
margin-left: 1em;
}
.search-page > form div.submit input#submit {
width: fit-content;
}
.search-page > form div.date {
grid-area: date;
display: flex;
align-items: center;
}
.search-page > form div.date input#date {
width: 80%;
}
.search-page > form div.sort {
grid-area: sort;
display: flex;
align-items: center;
margin-left: 2em;
}
.search-page > form div.scope {
grid-area: scope;
width: 80%;
margin-left: 1em;
}
.search-page > form div.scope select {
width: 100%;
height: 31rem;
overflow: auto;
}
.search-page > form div.search-results {
grid-area: results;
width: 100%;
min-height: 50vh;
}

View File

@ -0,0 +1,74 @@
.search-page > form {
display: grid;
grid-template-areas:
'search search submit'
'date sort scope'
'results results scope';
grid-template-rows: 40% 40% 20%;
grid-template-rows: 5em 5em 100%;
input, select {
width: 100%;
height: 2rem;
}
label {
margin-right: 1em;
}
& div.query {
grid-area: search;
display: flex;
align-items: center;
& label {
margin-right: 1em;
}
}
& div.submit {
grid-area: submit;
display: flex;
align-items: center;
margin-left: 1em;
& input#submit {
width: fit-content;
}
}
& div.date {
grid-area: date;
display: flex;
align-items: center;
& input#date {
width: 80%;
}
}
& div.sort {
grid-area: sort;
display: flex;
align-items: center;
margin-left: 2em;
}
& div.scope {
grid-area: scope;
width: 80%;
margin-left: 1em;
& select {
width: 100%;
height: 31rem;
overflow: auto;
}
}
& div.search-results {
grid-area: results;
width: 100%;
min-height: 50vh;
}
}

View File

@ -1,21 +1,69 @@
{% extends "base/base.html" %}
{% import "widgets/pagination.html" as widget_pagination with context %}
{% set tabtitle = "Recherche avancée" %}
{% block content %}
<section class="form">
<section class="search-page">
<h1>Recherche avancée</h1>
<form action="" method="get">
<div>
<form action="{{ url_for('search') }}" method="get">
{{ form.csrf_token }}
<div class="query">
{{ form.q.label }}
{{ form.q(value=request.args.get('q')) }}
{% for error in form.q.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
<div class="submit">
{{ form.submit(class_="bg-ok") }}
{% for error in form.submit.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div class="date">
{{ form.date.label }}
{{ form.date }}
{{ form.date(value=request.args.get('date')) }}
{% for error in form.date.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div class="sort">
{{ form.sortBy.label }}
{{ form.sortBy(value=request.args.get('sortBy')) }}
{% for error in form.sortBy.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div class="scope">
{{ form.scope.label }}
{{ form.scope(value=request.args.get('scope')) }}
{% for error in form.scope.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div class="search-results">
{{ widget_pagination.paginate(results, 'search', None, {
'q': request.args.get('q'),
'date': request.args.get('date'),
'sortBy': request.args.get('sortBy')}) }}
{% for i in results.items %}
<div>
{{ i.id }} {{ i.title }}<br>
{% if i.forum %}
<a href="{{ url_for('forum_topic', f=i.forum, page=(i , 'fin')) }}">{{ i.title }}</a>
{% elif i.thread %}
<a href="{{ url_for('redirect_post', postid=i.id) }}">{{ i.thread.owner_topic[0].title }}</a>
{% endif %}
{{ i.headline }}<br>
</div>
{% endfor %}
{{ widget_pagination.paginate(results, 'search', None, {
'q': request.args.get('q'),
'date': request.args.get('date'),
'sortBy': request.args.get('sortBy')}) }}
</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
</section>
{% endblock %}

View File

@ -19,6 +19,7 @@ def render(*args, styles=[], scripts=[], modules=[], **kwargs):
'css/debugger.css',
'css/programs.css',
'css/editor.css',
'css/search.css',
]
scripts_ = [
'scripts/trigger_menu.js',

View File

@ -0,0 +1,34 @@
"""Search functions
Revision ID: a803745f7840
Revises: 5ffc4e562ed8
Create Date: 2023-06-27 23:10:06.088917
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a803745f7840'
down_revision = '5ffc4e562ed8'
branch_labels = None
depends_on = None
def upgrade():
op.execute("""CREATE FUNCTION websearch_to_tsquery_multilang(text) RETURNS tsquery AS $$
SELECT websearch_to_tsquery('french', $1) ||
websearch_to_tsquery('english', $1) ||
websearch_to_tsquery('simple', $1)
$$ LANGUAGE sql IMMUTABLE;""")
op.execute("""CREATE FUNCTION to_tsvector_multilang(text) RETURNS tsvector AS $$
SELECT to_tsvector('french', $1) ||
to_tsvector('english', $1) ||
to_tsvector('simple', $1)
$$ LANGUAGE sql IMMUTABLE;""")
def downgrade():
op.execute("DROP FUNCTION websearch_to_tsquery_multilang(text);")
op.execute("DROP FUNCTION to_tsvector_multilang(text);")

View File

@ -0,0 +1,24 @@
"""empty message
Revision ID: dc99ddd17cf3
Revises: a803745f7840, a8f539a93bd5
Create Date: 2023-08-08 19:46:55.441606
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dc99ddd17cf3'
down_revision = ('a803745f7840', 'a8f539a93bd5')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass