Add advanced search interface

Fixes #112
This commit is contained in:
rubenwardy 2024-06-11 21:24:39 +01:00
parent d5492cbb9b
commit e75f2f92e2
6 changed files with 148 additions and 4 deletions

@ -84,4 +84,4 @@ def get_package_tabs(user: User, package: Package):
return retval return retval
from . import packages, screenshots, releases, reviews, game_hub from . import packages, advanced_search, screenshots, releases, reviews, game_hub

@ -0,0 +1,92 @@
# ContentDB
# Copyright (C) 2024 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template
from flask_babel import lazy_gettext, gettext
from flask_wtf import FlaskForm
from wtforms.fields.choices import SelectField, SelectMultipleField
from wtforms.fields.simple import StringField, BooleanField
from wtforms.validators import Optional
from wtforms_sqlalchemy.fields import QuerySelectMultipleField, QuerySelectField
from . import bp
from ...models import PackageType, Tag, db, ContentWarning, License, Language, MinetestRelease
def make_label(obj: Tag | ContentWarning):
translated = obj.get_translated()
if translated["description"]:
return "{}: {}".format(translated["title"], translated["description"])
else:
return translated["title"]
def get_hide_choices():
ret = [
("android_default", gettext("Android Default")),
("desktop_default", gettext("Desktop Default")),
("nonfree", gettext("Non-free")),
("wip", gettext("Work in Progress")),
("deprecated", gettext("Deprecated")),
("*", gettext("All content warnings")),
]
content_warnings = ContentWarning.query.order_by(db.asc(ContentWarning.name)).all()
tags = Tag.query.order_by(db.asc(Tag.name)).all()
ret += [(x.name, make_label(x)) for x in content_warnings + tags]
return ret
class AdvancedSearchForm(FlaskForm):
q = StringField(lazy_gettext("Query"), [Optional()])
type = SelectMultipleField(lazy_gettext("Type"), [Optional()], choices=PackageType.choices(),
coerce=PackageType.coerce)
author = StringField(lazy_gettext("Author"), [Optional()])
tag = QuerySelectMultipleField(lazy_gettext('Tags'),
query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)),
get_pk=lambda a: a.id, get_label=make_label)
flag = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=make_label)
license = QuerySelectMultipleField(lazy_gettext("License"), [Optional()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
game = StringField(lazy_gettext("Supports Game"), [Optional()])
lang = QuerySelectField(lazy_gettext("Language"), allow_blank=True,
query_factory=lambda: Language.query.order_by(db.asc(Language.title)),
get_pk=lambda a: a.id, get_label=lambda a: a.title)
hide = SelectMultipleField(lazy_gettext("Hide Tags and Content Warnings"), [Optional()])
engine_version = QuerySelectField(lazy_gettext("Minetest Version"), allow_blank=True,
query_factory=lambda: MinetestRelease.query.order_by(db.asc(MinetestRelease.id)),
get_pk=lambda a: a.value, get_label=lambda a: a.name)
sort = SelectField(lazy_gettext("Sort by"), [Optional()], choices=[
("", ""),
("name", lazy_gettext("Name")),
("title", lazy_gettext("Title")),
("score", lazy_gettext("Package score")),
("reviews", lazy_gettext("Reviews")),
("downloads", lazy_gettext("Downloads")),
("created_at", lazy_gettext("Created At")),
("approved_at", lazy_gettext("Approved At")),
("last_release", lazy_gettext("Last Release")),
])
order = SelectField(lazy_gettext("Order"), [Optional()], choices=[
("desc", lazy_gettext("Descending")),
("asc", lazy_gettext("Ascending")),
])
random = BooleanField(lazy_gettext("Random order"))
@bp.route("/packages/advanced-search/")
def advanced_search():
form = AdvancedSearchForm()
form.hide.choices = get_hide_choices()
return render_template("packages/advanced_search.html", form=form)

@ -126,7 +126,7 @@ class PackageType(enum.Enum):
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice, choice.text) for choice in cls] return [(choice.name.lower(), choice.text) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
@ -853,7 +853,7 @@ class Package(db.Model):
} }
def recalculate_score(self): def recalculate_score(self):
review_scores = [ 100 * r.as_weight() for r in self.reviews ] review_scores = [ 150 * r.as_weight() for r in self.reviews ]
self.score = self.score_downloads + sum(review_scores) self.score = self.score_downloads + sum(review_scores)
def get_conf_file_name(self): def get_conf_file_name(self):
@ -1042,6 +1042,10 @@ class MinetestRelease(db.Model):
self.name = name self.name = name
self.protocol = protocol self.protocol = protocol
@property
def value(self):
return self.name
def get_actual(self): def get_actual(self):
return None if self.name == "None" else self return None if self.name == "None" else self

@ -138,6 +138,8 @@ class QueryBuilder:
self.lucky = "lucky" in args self.lucky = "lucky" in args
self.limit = 1 if self.lucky else get_int_or_abort(args.get("limit"), None) self.limit = 1 if self.lucky else get_int_or_abort(args.get("limit"), None)
self.order_by = args.get("sort") self.order_by = args.get("sort")
if self.order_by == "":
self.order_by = None
self.order_dir = args.get("order") or "desc" self.order_dir = args.get("order") or "desc"
if "android_default" in self.hide_flags: if "android_default" in self.hide_flags:
@ -161,6 +163,9 @@ class QueryBuilder:
protocol_version = get_int_or_abort(args.get("protocol_version")) protocol_version = get_int_or_abort(args.get("protocol_version"))
minetest_version = args.get("engine_version") minetest_version = args.get("engine_version")
if minetest_version == "__None":
minetest_version = None
if protocol_version or minetest_version: if protocol_version or minetest_version:
self.version = MinetestRelease.get(minetest_version, protocol_version) self.version = MinetestRelease.get(minetest_version, protocol_version)
else: else:
@ -176,8 +181,14 @@ class QueryBuilder:
self.game = args.get("game") self.game = args.get("game")
if self.game: if self.game:
self.game = Package.get_by_key(self.game) self.game = Package.get_by_key(self.game)
if self.game is None:
abort(make_response("Unable to find that game"), 400)
else:
self.game = None
self.has_lang = args.get("lang") self.has_lang = args.get("lang")
if self.has_lang == "__None":
self.has_lang = None
if cookies and request.cookies.get("hide_nonfree") == "1": if cookies and request.cookies.get("hide_nonfree") == "1":
self.hide_nonfree = True self.hide_nonfree = True
@ -244,7 +255,7 @@ class QueryBuilder:
if self.game: if self.game:
query = query.filter(Package.supported_games.any(game=self.game, supports=True)) query = query.filter(Package.supported_games.any(game=self.game, supports=True))
if self.has_lang: if self.has_lang and self.has_lang != "en":
query = query.filter(Package.translations.any(language_id=self.has_lang)) query = query.filter(Package.translations.any(language_id=self.has_lang))
for tag in self.tags: for tag in self.tags:

@ -253,6 +253,7 @@
{% if request.endpoint != "flatpage" and request.endpoint != "report.report" %} {% if request.endpoint != "flatpage" and request.endpoint != "report.report" %}
<li class="list-inline-item"><a href="{{ url_for('report.report', url=url_current()) }}">{{ _("Report / DMCA") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('report.report', url=url_current()) }}">{{ _("Report / DMCA") }}</a></li>
{% endif %} {% endif %}
<li class="list-inline-item"><a href="{{ url_for('packages.advanced_search') }}">{{ _("Advanced Search") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('users.list_all') }}">{{ _("User List") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('users.list_all') }}">{{ _("User List") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('threads.list_all') }}">{{ _("Threads") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('threads.list_all') }}">{{ _("Threads") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('collections.list_all') }}">{{ _("Collections") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('collections.list_all') }}">{{ _("Collections") }}</a></li>

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}
{{ _("Advanced Search") }}
{% endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
{% from "macros/forms.html" import render_field, render_checkbox_field, render_submit_field %}
<form method="get" action="{{ url_for('packages.list_all') }}">
{{ render_field(form.q) }}
{{ render_field(form.type, hint=_("Use shift to select multiple. Leave selection empty to match any type.")) }}
{{ render_field(form.license, hint=_("Use shift to select multiple.")) }}
{{ render_field(form.lang) }}
<h2>{{ _("Tags and Content Warnings") }}</h2>
{{ render_field(form.tag, hint=_("Use shift to select multiple.")) }}
{{ render_field(form.flag, hint=_("Use shift to select multiple.")) }}
{{ render_field(form.hide, hint=_("Use shift to select multiple.")) }}
<h2>{{ _("Compatibility") }}</h2>
{{ render_field(form.engine_version) }}
{{ render_field(form.game, placeholder=_("author/name"), pattern="\w+/\w+", hint=_("author/name")) }}
<h2>{{ _("Sorting") }}</h2>
{{ render_field(form.sort) }}
{{ render_field(form.order) }}
{{ render_checkbox_field(form.random) }}
<button type="submit" class="btn btn-primary mt-5">{{ _("Search") }}</button>
</form>
{% endblock %}