From 380f00952992184771600771cfcf8eeab80afcd1 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Wed, 5 Jun 2024 19:27:09 +0100 Subject: [PATCH] Add option to filter packages by license --- app/blueprints/api/auth.py | 2 +- app/flatpages/help/api.md | 23 ++++++++++++++-------- app/models/packages.py | 2 +- app/querybuilder.py | 39 ++++++++++++++++++++++++++++++++------ app/utils/flask.py | 2 +- 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/app/blueprints/api/auth.py b/app/blueprints/api/auth.py index 44270a48..ddb489e4 100644 --- a/app/blueprints/api/auth.py +++ b/app/blueprints/api/auth.py @@ -39,7 +39,7 @@ def is_api_authd(f): if token is None: error(403, "Unknown API token") else: - abort(403, "Unsupported authentication method") + error(403, "Unsupported authentication method") return f(token=token, *args, **kwargs) diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index 8d816eeb..dd6e8a41 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -187,22 +187,29 @@ Example: /api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore -Supported query parameters: +Filter query parameters: -* `type`: Package types (`mod`, `game`, `txp`). +* `type`: Filter by package type (`mod`, `game`, `txp`). Multiple types are OR-ed together. * `q`: Query string. * `author`: Filter by author. -* `tag`: Filter by tags. +* `tag`: Filter by tags. Multiple tags are AND-ed together. * `flag`: Filter to show packages with [Content Flags](/help/content_flags/). +* `hide`: Hide content based on [Content Flags](/help/content_flags/). +* `license`: Filter by [license name](#licenses). Multiple licenses are OR-ed together, ie: `&license=MIT&license=LGPL-2.1-only` * `game`: Filter by [Game Support](/help/game_support/), ex: `Wuzzy/mineclone2`. (experimental, doesn't show items that support every game currently). * `lang`: Filter by translation support, eg: `en`/`de`/`ja`/`zh_TW`. -* `random`: When present, enable random ordering and ignore `sort`. -* `limit`: Return at most `limit` packages. -* `hide`: Hide content based on [Content Flags](/help/content_flags/). -* `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`). -* `order`: Sort ascending (`asc`) or descending (`desc`). * `protocol_version`: Only show packages supported by this Minetest protocol version. * `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`. + +Sorting query parameters: + +* `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`). +* `order`: Sort ascending (`asc`) or descending (`desc`). +* `random`: When present, enable random ordering and ignore `sort`. + +Format query parameters: + +* `limit`: Return at most `limit` packages. * `fmt`: How the response is formatted. * `keys`: author/name only. * `short`: stuff needed for the Minetest client. diff --git a/app/models/packages.py b/app/models/packages.py index 6b05ef79..70b2ad72 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -961,7 +961,7 @@ class MinetestRelease(db.Model): } @classmethod - def get(cls, version, protocol_num): + def get(cls, version: typing.Optional[str], protocol_num: typing.Optional[str]) -> typing.Optional["MinetestRelease"]: if version: parts = version.strip().split(".") if len(parts) >= 2: diff --git a/app/querybuilder.py b/app/querybuilder.py index 78a1fbd8..edda22b0 100644 --- a/app/querybuilder.py +++ b/app/querybuilder.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional -from flask import abort, current_app, request +from typing import Optional, List +from flask import abort, current_app, request, make_response from flask_babel import lazy_gettext, gettext, get_locale from sqlalchemy import or_ from sqlalchemy.orm import subqueryload @@ -28,10 +28,27 @@ from .utils import is_yes, get_int_or_abort class QueryBuilder: + limit: Optional[int] lang: str = "en" - types = None - search = None - only_approved = True + types: List[PackageType] + search: Optional[str] = None + only_approved: bool = True + licenses: List[License] + tags: List[Tag] + game: Optional[Package] + author: Optional[str] + random: bool + lucky: bool + order_dir: str + order_by: Optional[str] + flags: set[str] + hide_flags: set[str] + hide_deprecated: bool + hide_wip: bool + hide_nonfree: bool + show_added: bool + version: Optional[MinetestRelease] + has_lang: Optional[str] @property def title(self): @@ -62,7 +79,7 @@ class QueryBuilder: return (self.search is not None or len(self.tags) > 1 or len(self.types) > 1 or len(self.hide_flags) > 0 or self.random or self.lucky or self.author or self.version or self.game) - def __init__(self, args, cookies: bool = False, lang: Optional[str] = None): + def __init__(self, args, cookies: bool = False, lang: Optional[str] = None, emit_http_errors: bool = True): if lang is None: locale = get_locale() if locale: @@ -86,6 +103,11 @@ class QueryBuilder: # Show flags self.flags = set(args.getlist("flag")) + # License + self.licenses = [License.query.filter(func.lower(License.name) == name).first() for name in args.getlist("license")] + if emit_http_errors and any(map(lambda x: x is None, self.licenses)): + abort(make_response("Unknown license"), 400) + self.types = types self.tags = tags @@ -234,6 +256,11 @@ class QueryBuilder: if warning: query = query.filter(Package.content_warnings.any(ContentWarning.id == warning.id)) + licenses = [Package.license_id == license.id for license in self.licenses if license is not None] + licenses.extend([Package.media_license_id == license.id for license in self.licenses if license is not None]) + if len(licenses) > 0: + query = query.filter(or_(*licenses)) + if self.hide_nonfree: query = query.filter(Package.license.has(License.is_foss == True)) query = query.filter(Package.media_license.has(License.is_foss == True)) diff --git a/app/utils/flask.py b/app/utils/flask.py index d3998c1d..6feca7c1 100644 --- a/app/utils/flask.py +++ b/app/utils/flask.py @@ -115,7 +115,7 @@ def url_set_query(**kwargs): return url_for(request.endpoint, **dargs) -def get_int_or_abort(v, default=None): +def get_int_or_abort(v, default=None) -> typing.Optional[int]: if v is None: return default