Compare commits

..

20 Commits

Author SHA1 Message Date
rubenwardy
d58579d308 Document top packages algorithm 2020-04-21 18:26:03 +01:00
rubenwardy
0620c3e00f Add API to see scores 2020-04-21 18:15:13 +01:00
rubenwardy
a8374ec779 Allow all members to approve own releases 2020-04-21 17:07:04 +01:00
David Leal
24090235d1 Add build status badge on README.md (#194) 2020-04-20 23:01:59 +01:00
rubenwardy
bbaa687aa7 Format exception emails better 2020-04-14 14:45:06 +01:00
rubenwardy
dadfe72b48 Improve user authentication error handling 2020-04-14 14:39:59 +01:00
rubenwardy
9cc3eba009 Fix email sign up 2020-04-11 17:56:35 +01:00
rubenwardy
54a636d79e Fix access token not being shown after creation
Fixes #190
2020-04-11 17:45:25 +01:00
rubenwardy
0087c1ef9d Allow unlimited API tokens in GitHub webhooks 2020-04-11 15:24:44 +01:00
rubenwardy
39881e0d04 Improve error messages 2020-04-11 14:51:10 +01:00
rubenwardy
39a09c5d92 Add ability to search by tag 2020-04-07 18:23:06 +01:00
rubenwardy
663a9ba07b Fix exposing abs_url_for to templates 2020-03-28 19:01:39 +00:00
rubenwardy
144ae69f5c Fix case-insensitive comparison bug 2020-03-28 18:15:15 +00:00
rubenwardy
3e07bed51b Add ability to search packages by author 2020-03-28 18:13:03 +00:00
rubenwardy
9de219fd80 Increase package name and title length limits in form validation 2020-03-27 15:30:08 +00:00
rubenwardy
4a25435f7a Fix release validation for repos with submodules 2020-03-27 15:23:18 +00:00
rubenwardy
b0f32affcb Fix scores not degrading due to missing session.commit() 2020-03-22 19:47:52 +00:00
rubenwardy
99548ea65f Fix licenses being prefilled in package editor 2020-02-23 20:40:14 +00:00
rubenwardy
325ee02b49 Fix lack of download counter checks on non-release package download 2020-02-23 20:14:56 +00:00
rubenwardy
a60786d32c Fix non-admin users not being able to set profile URLs 2020-02-23 20:12:32 +00:00
23 changed files with 185 additions and 52 deletions

View File

@@ -1,7 +1,7 @@
# Content Database # Content Database
[![Build status](https://gitlab.com/minetest/contentdb/badges/master/pipeline.svg)](https://gitlab.com/minetest/contentdb/pipelines)
Content database for Minetest mods, games, and more. Content database for Minetest mods, games, and more.\
Developed by rubenwardy, license GPLv3.0+. Developed by rubenwardy, license GPLv3.0+.
## How-tos ## How-tos

View File

@@ -60,7 +60,6 @@ if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
from .markdown import init_app from .markdown import init_app
init_app(app) init_app(app)
# @babel.localeselector # @babel.localeselector
# def get_locale(): # def get_locale():
# return request.accept_languages.best_match(app.config['LANGUAGES'].keys()) # return request.accept_languages.best_match(app.config['LANGUAGES'].keys())

View File

@@ -37,6 +37,16 @@ def packages():
return jsonify(pkgs) return jsonify(pkgs)
@bp.route("/api/scores/")
def package_scores():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
pkgs = [{ "author": package.author.username, "name": package.name, "score": package.score } \
for package in query.all()]
return jsonify(pkgs)
@bp.route("/api/packages/<author>/<name>/") @bp.route("/api/packages/<author>/<name>/")
@is_package_page @is_package_page
def package(package): def package(package):

View File

@@ -69,7 +69,7 @@ def create_edit_token(username, id=None):
elif token.owner != user: elif token.owner != user:
abort(403) abort(403)
access_token = session.pop("token_" + str(id), None) access_token = session.pop("token_" + str(token.id), None)
form = CreateAPIToken(formdata=request.form, obj=token) form = CreateAPIToken(formdata=request.form, obj=token)
form.package.query_factory = lambda: Package.query.filter_by(author=user).all() form.package.query_factory = lambda: Package.query.filter_by(author=user).all()
@@ -80,13 +80,14 @@ def create_edit_token(username, id=None):
token.owner = user token.owner = user
token.access_token = randomString(32) token.access_token = randomString(32)
# Store token so it can be shown in the edit page
session["token_" + str(token.id)] = token.access_token
form.populate_obj(token) form.populate_obj(token)
db.session.add(token) db.session.add(token)
db.session.commit() # save db.session.commit() # save
if is_new:
# Store token so it can be shown in the edit page
session["token_" + str(token.id)] = token.access_token
return redirect(url_for("api.create_edit_token", username=username, id=token.id)) return redirect(url_for("api.create_edit_token", username=username, id=token.id))
return render_template("api/create_edit_token.html", user=user, form=form, token=token, access_token=access_token) return render_template("api/create_edit_token.html", user=user, form=form, token=token, access_token=access_token)
@@ -102,8 +103,6 @@ def reset_token(username, id):
if not user.checkPerm(current_user, Permission.CREATE_TOKEN): if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403) abort(403)
is_new = id is None
token = APIToken.query.get(id) token = APIToken.query.get(id)
if token is None: if token is None:
abort(404) abort(404)

View File

@@ -20,7 +20,7 @@ bp = Blueprint("github", __name__)
from flask import redirect, url_for, request, flash, abort, render_template, jsonify, current_app from flask import redirect, url_for, request, flash, abort, render_template, jsonify, current_app
from flask_user import current_user, login_required from flask_user import current_user, login_required
from sqlalchemy import func from sqlalchemy import func, or_, and_
from flask_github import GitHub from flask_github import GitHub
from app import github, csrf from app import github, csrf
from app.models import db, User, APIToken, Package, Permission from app.models import db, User, APIToken, Package, Permission
@@ -92,10 +92,13 @@ def webhook():
github_url = "github.com/" + json["repository"]["full_name"] github_url = "github.com/" + json["repository"]["full_name"]
package = Package.query.filter(Package.repo.like("%{}%".format(github_url))).first() package = Package.query.filter(Package.repo.like("%{}%".format(github_url))).first()
if package is None: if package is None:
return error(400, "Unknown package") return error(400, "Could not find package, did you set the VCS repo in CDB correctly?")
# Get all tokens for package # Get all tokens for package
possible_tokens = APIToken.query.filter_by(package=package).all() tokens_query = APIToken.query.filter(or_(APIToken.package==package,
and_(APIToken.package==None, APIToken.owner==package.author)))
possible_tokens = tokens_query.all()
actual_token = None actual_token = None
# #
@@ -118,7 +121,7 @@ def webhook():
break break
if actual_token is None: if actual_token is None:
return error(403, "Invalid authentication") return error(403, "Invalid authentication, couldn't validate API token")
if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE): if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
return error(403, "Only trusted members can use webhooks") return error(403, "Only trusted members can use webhooks")

View File

@@ -30,7 +30,7 @@ from flask_wtf import FlaskForm
from wtforms import * from wtforms import *
from wtforms.validators import * from wtforms.validators import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from sqlalchemy import or_ from sqlalchemy import or_, func
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' }) @menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
@@ -64,6 +64,14 @@ def list_all():
prev_url = url_for("packages.list_all", type=type_name, q=search, page=query.prev_num) \ prev_url = url_for("packages.list_all", type=type_name, q=search, page=query.prev_num) \
if query.has_prev else None if query.has_prev else None
authors = []
if search:
authors = User.query \
.filter(or_(*[func.lower(User.username) == name.lower().strip() for name in search.split(" ")])) \
.all()
authors = [(author.username, search.lower().replace(author.username.lower(), "")) for author in authors]
topics = None topics = None
if qb.search and not query.has_next: if qb.search and not query.has_next:
qb.show_discarded = True qb.show_discarded = True
@@ -73,6 +81,7 @@ def list_all():
return render_template("packages/list.html", \ return render_template("packages/list.html", \
title=title, packages=query.items, topics=topics, \ title=title, packages=query.items, topics=topics, \
query=search, tags=tags, type=type_name, \ query=search, tags=tags, type=type_name, \
authors = authors, \
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, packages_count=query.total) next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, packages_count=query.total)
@@ -164,22 +173,17 @@ def download(package):
flash("No download available.", "danger") flash("No download available.", "danger")
return redirect(package.getDetailsURL()) return redirect(package.getDetailsURL())
else: else:
PackageRelease.query.filter_by(id=release.id).update({ return redirect(release.getDownloadURL(), code=302)
"downloads": PackageRelease.downloads + 1
})
db.session.commit()
return redirect(release.url, code=302)
class PackageForm(FlaskForm): class PackageForm(FlaskForm):
name = StringField("Name (Technical)", [InputRequired(), Length(1, 20), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")]) name = StringField("Name (Technical)", [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 50)]) title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 100)])
short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)]) short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)]) desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD) type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
license = QuerySelectField("License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name) license = QuerySelectField("License", [DataRequired()], 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)
media_license = QuerySelectField("Media License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name) media_license = QuerySelectField("Media License", [DataRequired()], 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)
provides_str = StringField("Provides (mods included in package)", [Optional()]) provides_str = StringField("Provides (mods included in package)", [Optional()])
tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=lambda a: a.title) tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=lambda a: a.title)
harddep_str = StringField("Hard Dependencies", [Optional()]) harddep_str = StringField("Hard Dependencies", [Optional()])
@@ -227,6 +231,8 @@ def create_edit(author=None, name=None):
form.title.data = request.args.get("title") form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo") form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums") form.forums.data = request.args.get("forums")
form.license.data = None
form.media_license.data = None
else: else:
form.harddep_str.data = ",".join([str(x) for x in package.getSortedHardDependencies() ]) form.harddep_str.data = ",".join([str(x) for x in package.getSortedHardDependencies() ])
form.softdep_str.data = ",".join([str(x) for x in package.getSortedOptionalDependencies() ]) form.softdep_str.data = ",".join([str(x) for x in package.getSortedOptionalDependencies() ])

View File

@@ -34,15 +34,16 @@ def claim():
if user and user.rank.atLeast(UserRank.NEW_MEMBER): if user and user.rank.atLeast(UserRank.NEW_MEMBER):
flash("User has already been claimed", "danger") flash("User has already been claimed", "danger")
return redirect(url_for("users.claim")) return redirect(url_for("users.claim"))
elif user is None and method == "github": elif method == "github":
flash("Unable to get Github username for user", "danger") if user is None or user.github_username is None:
return redirect(url_for("users.claim")) flash("Unable to get Github username for user", "danger")
elif user is None: return redirect(url_for("users.claim"))
flash("Unable to find that user", "danger") else:
return redirect(url_for("github.start"))
elif user is None and request.method == "POST":
flash("Unable to find user", "danger")
return redirect(url_for("users.claim")) return redirect(url_for("users.claim"))
if user is not None and method == "github":
return redirect(url_for("github.start"))
token = None token = None
if "forum_token" in session: if "forum_token" in session:
@@ -70,8 +71,17 @@ def claim():
sig = None sig = None
try: try:
profile = getProfile("https://forum.minetest.net", username) profile = getProfile("https://forum.minetest.net", username)
sig = profile.signature sig = profile.signature if profile else None
except IOError: except IOError as e:
if hasattr(e, 'message'):
message = e.message
else:
message = str(e)
flash("Error whilst attempting to access forums: " + message, "danger")
return redirect(url_for("users.claim", username=username))
if profile is None:
flash("Unable to get forum signature - does the user exist?", "danger") flash("Unable to get forum signature - does the user exist?", "danger")
return redirect(url_for("users.claim", username=username)) return redirect(url_for("users.claim", username=username))

View File

@@ -16,7 +16,7 @@
from flask import * from flask import *
from flask_user import * from flask_user import signals, current_user, user_manager
from flask_login import login_user, logout_user from flask_login import login_user, logout_user
from app.markdown import render_markdown from app.markdown import render_markdown
from . import bp from . import bp
@@ -63,6 +63,8 @@ def profile(username):
# Copy form fields to user_profile fields # Copy form fields to user_profile fields
if user.checkPerm(current_user, Permission.CHANGE_DNAME): if user.checkPerm(current_user, Permission.CHANGE_DNAME):
user.display_name = form["display_name"].data user.display_name = form["display_name"].data
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
user.website_url = form["website_url"].data user.website_url = form["website_url"].data
user.donate_url = form["donate_url"].data user.donate_url = form["donate_url"].data
@@ -190,7 +192,7 @@ def set_password():
# Send 'password_changed' email # Send 'password_changed' email
if user_manager.USER_ENABLE_EMAIL and current_user.email: if user_manager.USER_ENABLE_EMAIL and current_user.email:
emails.send_password_changed_email(current_user) user_manager.email_manager.send_password_changed_email(current_user)
# Send password_changed signal # Send password_changed signal
signals.user_changed_password.send(current_app._get_current_object(), user=current_user) signals.user_changed_password.send(current_app._get_current_object(), user=current_user)

View File

@@ -4,5 +4,6 @@ title: Help
* [Ranks and Permissions](ranks_permissions) * [Ranks and Permissions](ranks_permissions)
* [Content Ratings and Flags](content_flags) * [Content Ratings and Flags](content_flags)
* [Reporting Content](reporting) * [Reporting Content](reporting)
* [Top Packages Algorithm](top_packages)
* [API](api) * [API](api)
* [Creating Releases using Webhooks](release_webhooks) * [Creating Releases using Webhooks](release_webhooks)

View File

@@ -21,6 +21,7 @@ You can use the `/api/whoami` to check authentication.
### Packages ### Packages
* GET `/api/packages/` - See [Package Queries](#package-queries) * GET `/api/packages/` - See [Package Queries](#package-queries)
* GET `/api/scores/` - See [Package Queries](#package-queries)
* GET `/api/packages/<username>/<name>/` * GET `/api/packages/<username>/<name>/`
### Releases ### Releases

View File

@@ -0,0 +1,39 @@
title: Top Packages Algorithm
## Pseudo rolling average
Every package loses 5% of its score every day.
An open source package will gain 1 score for each unique download,
whereas a non-free package will only gain 0.1 score.
A package currently only gains score through downloads.
In the future, a package will also gain score through reviews.
## Seed using a legacy heuristic
The scoring system was seeded (ie: the scores were initially set to) 20% of an
arbitrary legacy heuristic that was previously used to rank packages.
This legacy heuristic is as follows:
forum_score = views / max(years_since_creation, 2 weeks) + 80*clamp(months, 0.5, 6)
forum_bonus = views + posts
multiplier = 1
if no screenshot:
multiplier *= 0.8
if not foss:
multiplier *= 0.1
score = multiplier * (max(downloads, forum_score * 0.6) + forum_bonus)
As said, this legacy score is no longer used when ranking mods.
It was only used to provide an initial score for the rolling average,
which was 20% of the above value.
## Transparency and Feedback
You can see all scores using the [scores REST API](/api/scores/).
Consider [suggesting improvements](https://github.com/minetest/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).

View File

@@ -41,7 +41,7 @@ class FlaskMailHTMLFormatter(logging.Formatter):
formatted_exception = logging.Handler.formatException(self, exc_info) formatted_exception = logging.Handler.formatException(self, exc_info)
return FlaskMailHTMLFormatter.pre_template % ("Exception information", formatted_exception) return FlaskMailHTMLFormatter.pre_template % ("Exception information", formatted_exception)
def formatStack(self, stack_info): def formatStack(self, stack_info):
return FlaskMailHTMLFormatter.pre_template % ("<h1>Stack information</h1><pre>%s</pre>", stack_info) return FlaskMailHTMLFormatter.pre_template % ("<h1>Stack information</h1><pre><code>%s</code></pre>", stack_info)
# see: https://github.com/python/cpython/blob/3.6/Lib/logging/__init__.py (class Handler) # see: https://github.com/python/cpython/blob/3.6/Lib/logging/__init__.py (class Handler)
@@ -100,7 +100,7 @@ Message:
<tr> <th>Time:</th><td>%(asctime)s</td></tr> <tr> <th>Time:</th><td>%(asctime)s</td></tr>
</table> </table>
<h2>Message</h2> <h2>Message</h2>
<pre>%(message)s</pre>""" <pre><code>%(message)s</code></pre>"""
import logging import logging
mail_handler = FlaskMailHandler(mailer, subject_template) mail_handler = FlaskMailHandler(mailer, subject_template)

View File

@@ -93,6 +93,7 @@ class Permission(enum.Enum):
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE" UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
TOPIC_DISCARD = "TOPIC_DISCARD" TOPIC_DISCARD = "TOPIC_DISCARD"
CREATE_TOKEN = "CREATE_TOKEN" CREATE_TOKEN = "CREATE_TOKEN"
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
# Only return true if the permission is valid for *all* contexts # Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts # See Package.checkPerm for package-specific contexts
@@ -192,7 +193,7 @@ class User(db.Model, UserMixin):
return user.rank.atLeast(UserRank.EDITOR) return user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_DNAME: elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_DNAME:
return user.rank.atLeast(UserRank.MODERATOR) return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.CHANGE_EMAIL: elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)) return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
elif perm == Permission.CREATE_TOKEN: elif perm == Permission.CREATE_TOKEN:
if user == self: if user == self:
@@ -213,6 +214,9 @@ class User(db.Model, UserMixin):
.filter(Thread.created_at > hour_ago).count() < 2 .filter(Thread.created_at > hour_ago).count() < 2
def __eq__(self, other): def __eq__(self, other):
if other is None:
return False
if not self.is_authenticated or not other.is_authenticated: if not self.is_authenticated or not other.is_authenticated:
return False return False
@@ -655,7 +659,7 @@ class Package(db.Model):
if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS: if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
return isOwner or user.rank.atLeast(UserRank.EDITOR) return isOwner or user.rank.atLeast(UserRank.EDITOR)
if perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES: if perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES or perm == Permission.APPROVE_RELEASE:
if isOwner: if isOwner:
return user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER) return user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
else: else:
@@ -669,7 +673,7 @@ class Package(db.Model):
elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR: elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
return user.rank.atLeast(UserRank.EDITOR) return user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.APPROVE_RELEASE or perm == Permission.APPROVE_SCREENSHOT: elif perm == Permission.APPROVE_SCREENSHOT:
return user.rank.atLeast(UserRank.TRUSTED_MEMBER if isOwner else UserRank.EDITOR) return user.rank.atLeast(UserRank.TRUSTED_MEMBER if isOwner else UserRank.EDITOR)
# Moderators can delete packages # Moderators can delete packages

View File

@@ -1,4 +1,5 @@
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag
from .models import tags as Tags
from .utils import isNo, isYes from .utils import isNo, isYes
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from flask import abort from flask import abort
@@ -19,18 +20,30 @@ class QueryBuilder:
if len(types) > 0: if len(types) > 0:
title = ", ".join([type.value + "s" for type in types]) title = ", ".join([type.value + "s" for type in types])
# Get tags types
tags = args.getlist("tag")
tags = [Tag.query.filter_by(name=tname).first() for tname in tags]
tags = [tag for tag in tags if tag is not None]
# Hid
hide_flags = args.getlist("hide") hide_flags = args.getlist("hide")
self.title = title self.title = title
self.types = types self.types = types
self.search = args.get("q") self.tags = tags
self.random = "random" in args self.random = "random" in args
self.lucky = "lucky" in args self.lucky = "lucky" in args
self.hide_nonfree = "nonfree" in hide_flags self.hide_nonfree = "nonfree" in hide_flags
self.limit = 1 if self.lucky else None self.limit = 1 if self.lucky else None
self.order_by = args.get("sort") self.order_by = args.get("sort")
self.order_dir = args.get("order") or "desc" self.order_dir = args.get("order") or "desc"
# Filters
self.search = args.get("q")
self.protocol_version = args.get("protocol_version") self.protocol_version = args.get("protocol_version")
self.author = args.get("author")
self.show_discarded = isYes(args.get("show_discarded")) self.show_discarded = isYes(args.get("show_discarded"))
self.show_added = args.get("show_added") self.show_added = args.get("show_added")
@@ -84,6 +97,16 @@ class QueryBuilder:
query = query.order_by(to_order) query = query.order_by(to_order)
if self.author:
author = User.query.filter_by(username=self.author).first()
if not author:
abort(404)
query = query.filter_by(author=author)
for tag in self.tags:
query = query.filter(Package.tags.any(Tag.id == tag.id))
if self.hide_nonfree: if self.hide_nonfree:
query = query.filter(Package.license.has(License.is_foss == True)) query = query.filter(Package.license.has(License.is_foss == True))
query = query.filter(Package.media_license.has(License.is_foss == True)) query = query.filter(Package.media_license.has(License.is_foss == True))

View File

@@ -162,6 +162,9 @@ def cloneRepo(urlstr, ref=None, recursive=False):
origin.fetch() origin.fetch()
origin.pull(ref) origin.pull(ref)
for submodule in repo.submodules:
submodule.update(init=True)
return gitDir, repo return gitDir, repo
except GitCommandError as e: except GitCommandError as e:

View File

@@ -74,7 +74,14 @@ def __extract_signature(soup):
def getProfile(url, username): def getProfile(url, username):
url = url + "/memberlist.php?mode=viewprofile&un=" + urlEncodeNonAscii(username) url = url + "/memberlist.php?mode=viewprofile&un=" + urlEncodeNonAscii(username)
contents = urllib.request.urlopen(url).read().decode("utf-8") req = urllib.request.urlopen(url, timeout=5)
if req.getcode() == 404:
return None
if req.getcode() != 200:
raise IOError(req.getcode())
contents = req.read().decode("utf-8")
soup = BeautifulSoup(contents, "lxml") soup = BeautifulSoup(contents, "lxml")
if soup is None: if soup is None:
return None return None

View File

@@ -15,9 +15,10 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from app.models import Package from app.models import Package, db
from app.tasks import celery from app.tasks import celery
@celery.task() @celery.task()
def updatePackageScores(): def updatePackageScores():
Package.query.update({ "score": Package.score * 0.8 }) Package.query.update({ "score": Package.score * 0.95 })
db.session.commit()

View File

@@ -1,10 +1,16 @@
from . import app from . import app
from .utils import abs_url_for
from urllib.parse import urlparse from urllib.parse import urlparse
@app.context_processor @app.context_processor
def inject_debug(): def inject_debug():
return dict(debug=app.debug) return dict(debug=app.debug)
@app.context_processor
def inject_functions():
return dict(abs_url_for=abs_url_for)
@app.template_filter() @app.template_filter()
def throw(err): def throw(err):
raise Exception(err) raise Exception(err)

View File

@@ -129,7 +129,7 @@
{% endblock %} {% endblock %}
<footer class="container footer-copyright my-5 page-footer font-small text-center"> <footer class="container footer-copyright my-5 page-footer font-small text-center">
ContentDB &copy; 2018-9 to <a href="https://rubenwardy.com/">rubenwardy</a> | ContentDB &copy; 2018-20 to <a href="https://rubenwardy.com/">rubenwardy</a> |
<a href="https://github.com/minetest/contentdb">GitHub</a> | <a href="https://github.com/minetest/contentdb">GitHub</a> |
<a href="{{ url_for('flatpage', path='help') }}">{{ _("Help") }}</a> | <a href="{{ url_for('flatpage', path='help') }}">{{ _("Help") }}</a> |
<a href="{{ url_for('flatpage', path='policy_and_guidance') }}">{{ _("Policy and Guidance") }}</a> | <a href="{{ url_for('flatpage', path='policy_and_guidance') }}">{{ _("Policy and Guidance") }}</a> |

View File

@@ -5,6 +5,21 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if authors %}
<p class="alert alert-primary">
Did you mean to search for packages by
{% for author in authors %}
<a href="{{ url_for('packages.list_all', type=type, author=author[0], q=author[1]) }}">{{ author[0] }}</a>
{% if not loop.last %}
,
{% endif %}
{% endfor %}
?
</p>
{% endif %}
{% from "macros/packagegridtile.html" import render_pkggrid %} {% from "macros/packagegridtile.html" import render_pkggrid %}
{{ render_pkggrid(packages) }} {{ render_pkggrid(packages) }}

View File

@@ -38,7 +38,8 @@
</span> </span>
{% endif %} {% endif %}
{% for t in package.tags %} {% for t in package.tags %}
<span class="badge badge-primary">{{ t.title }}</span> <a class="badge badge-primary"
href="{{ url_for('packages.list_all', tag=t.name) }}">{{ t.title }}</a>
{% endfor %} {% endfor %}
</p> </p>

View File

@@ -164,6 +164,9 @@
{% if user.checkPerm(current_user, "CHANGE_DNAME") %} {% if user.checkPerm(current_user, "CHANGE_DNAME") %}
{{ render_field(form.display_name, tabindex=230) }} {{ render_field(form.display_name, tabindex=230) }}
{% endif %}
{% if user.checkPerm(current_user, "CHANGE_PROFILE_URLS") %}
{{ render_field(form.website_url, tabindex=232) }} {{ render_field(form.website_url, tabindex=232) }}
{{ render_field(form.donate_url, tabindex=233) }} {{ render_field(form.donate_url, tabindex=233) }}
{% endif %} {% endif %}
@@ -177,7 +180,9 @@
{{ render_field(form.rank, tabindex=250) }} {{ render_field(form.rank, tabindex=250) }}
{% endif %} {% endif %}
{{ render_submit_field(form.submit, tabindex=280) }} <p>
{{ render_submit_field(form.submit, tabindex=280) }}
</p>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -18,12 +18,10 @@
from flask import request, flash, abort, redirect from flask import request, flash, abort, redirect
from flask_user import * from flask_user import *
from flask_login import login_user, logout_user from flask_login import login_user, logout_user
from app.models import * from .models import *
from app import app from . import app
import random, string, os, imghdr import random, string, os, imghdr
@app.template_filter()
def abs_url_for(path, **kwargs): def abs_url_for(path, **kwargs):
scheme = "https" if app.config["BASE_URL"][:5] == "https" else "http" scheme = "https" if app.config["BASE_URL"][:5] == "https" else "http"
return url_for(path, _external=True, _scheme=scheme, **kwargs) return url_for(path, _external=True, _scheme=scheme, **kwargs)