From e75f2f92e2ad8a297ac9e927327b06ef1b22c541 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Tue, 11 Jun 2024 21:24:39 +0100 Subject: [PATCH] Add advanced search interface Fixes #112 --- app/blueprints/packages/__init__.py | 2 +- app/blueprints/packages/advanced_search.py | 92 +++++++++++++++++++++ app/models/packages.py | 8 +- app/querybuilder.py | 13 ++- app/templates/base.html | 1 + app/templates/packages/advanced_search.html | 36 ++++++++ 6 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 app/blueprints/packages/advanced_search.py create mode 100644 app/templates/packages/advanced_search.html diff --git a/app/blueprints/packages/__init__.py b/app/blueprints/packages/__init__.py index aab0512e..3335abae 100644 --- a/app/blueprints/packages/__init__.py +++ b/app/blueprints/packages/__init__.py @@ -84,4 +84,4 @@ def get_package_tabs(user: User, package: Package): return retval -from . import packages, screenshots, releases, reviews, game_hub +from . import packages, advanced_search, screenshots, releases, reviews, game_hub diff --git a/app/blueprints/packages/advanced_search.py b/app/blueprints/packages/advanced_search.py new file mode 100644 index 00000000..75246857 --- /dev/null +++ b/app/blueprints/packages/advanced_search.py @@ -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 . + +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) diff --git a/app/models/packages.py b/app/models/packages.py index 81f37871..e12d1d99 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -126,7 +126,7 @@ class PackageType(enum.Enum): @classmethod def choices(cls): - return [(choice, choice.text) for choice in cls] + return [(choice.name.lower(), choice.text) for choice in cls] @classmethod def coerce(cls, item): @@ -853,7 +853,7 @@ class Package(db.Model): } 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) def get_conf_file_name(self): @@ -1042,6 +1042,10 @@ class MinetestRelease(db.Model): self.name = name self.protocol = protocol + @property + def value(self): + return self.name + def get_actual(self): return None if self.name == "None" else self diff --git a/app/querybuilder.py b/app/querybuilder.py index 52dd559d..a02ce101 100644 --- a/app/querybuilder.py +++ b/app/querybuilder.py @@ -138,6 +138,8 @@ class QueryBuilder: self.lucky = "lucky" in args self.limit = 1 if self.lucky else get_int_or_abort(args.get("limit"), None) self.order_by = args.get("sort") + if self.order_by == "": + self.order_by = None self.order_dir = args.get("order") or "desc" if "android_default" in self.hide_flags: @@ -161,6 +163,9 @@ class QueryBuilder: protocol_version = get_int_or_abort(args.get("protocol_version")) minetest_version = args.get("engine_version") + if minetest_version == "__None": + minetest_version = None + if protocol_version or minetest_version: self.version = MinetestRelease.get(minetest_version, protocol_version) else: @@ -176,8 +181,14 @@ class QueryBuilder: self.game = args.get("game") if 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") + if self.has_lang == "__None": + self.has_lang = None if cookies and request.cookies.get("hide_nonfree") == "1": self.hide_nonfree = True @@ -244,7 +255,7 @@ class QueryBuilder: if self.game: 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)) for tag in self.tags: diff --git a/app/templates/base.html b/app/templates/base.html index 2256f8b6..487c4274 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -253,6 +253,7 @@ {% if request.endpoint != "flatpage" and request.endpoint != "report.report" %}
  • {{ _("Report / DMCA") }}
  • {% endif %} +
  • {{ _("Advanced Search") }}
  • {{ _("User List") }}
  • {{ _("Threads") }}
  • {{ _("Collections") }}
  • diff --git a/app/templates/packages/advanced_search.html b/app/templates/packages/advanced_search.html new file mode 100644 index 00000000..2f1a7697 --- /dev/null +++ b/app/templates/packages/advanced_search.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +{{ _("Advanced Search") }} +{% endblock %} + +{% block content %} +

    {{ self.title() }}

    + + {% from "macros/forms.html" import render_field, render_checkbox_field, render_submit_field %} +
    + {{ 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) }} + +

    {{ _("Tags and Content Warnings") }}

    + + {{ 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.")) }} + +

    {{ _("Compatibility") }}

    + + {{ render_field(form.engine_version) }} + {{ render_field(form.game, placeholder=_("author/name"), pattern="\w+/\w+", hint=_("author/name")) }} + +

    {{ _("Sorting") }}

    + + {{ render_field(form.sort) }} + {{ render_field(form.order) }} + {{ render_checkbox_field(form.random) }} + + +
    +{% endblock %}