Compare commits
2 Commits
master
...
unify_pack
Author | SHA1 | Date |
---|---|---|
rubenwardy | a93eea8612 | |
rubenwardy | a6ab2d6f79 |
|
@ -3,4 +3,3 @@ data*
|
|||
uploads
|
||||
*.pyc
|
||||
__pycache__
|
||||
env
|
||||
|
|
|
@ -11,7 +11,6 @@ app/public/thumbnails
|
|||
celerybeat-schedule
|
||||
/data
|
||||
.idea
|
||||
*.mo
|
||||
|
||||
# Created by https://www.gitignore.io/api/linux,macos,python,windows
|
||||
|
||||
|
@ -106,6 +105,10 @@ coverage.xml
|
|||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.10
|
||||
FROM python:3.6
|
||||
|
||||
RUN groupadd -g 5123 cdb && \
|
||||
useradd -r -u 5123 -g cdb cdb
|
||||
|
@ -16,9 +16,7 @@ COPY utils utils
|
|||
COPY config.cfg config.cfg
|
||||
COPY migrations migrations
|
||||
COPY app app
|
||||
COPY translations translations
|
||||
|
||||
RUN pybabel compile -d translations
|
||||
RUN chown -R cdb:cdb /home/cdb
|
||||
|
||||
USER cdb
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
Content database for Minetest mods, games, and more.\
|
||||
Developed by rubenwardy, license AGPLv3.0+.
|
||||
|
||||
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
||||
|
||||
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
|
||||
See [Getting Started](docs/getting_started.md).
|
||||
|
||||
## How-tos
|
||||
|
||||
|
|
100
app/__init__.py
|
@ -13,60 +13,54 @@
|
|||
#
|
||||
# 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/>.
|
||||
import datetime
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask_gravatar import Gravatar
|
||||
import flask_menu as menu
|
||||
from flask_mail import Mail
|
||||
from flask_github import GitHub
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_flatpages import FlatPages
|
||||
from flask_babel import Babel, gettext
|
||||
from flask_babel import Babel
|
||||
from flask_login import logout_user, current_user, LoginManager
|
||||
import os, redis
|
||||
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder="public/static")
|
||||
app.config["FLATPAGES_ROOT"] = "flatpages"
|
||||
app.config["FLATPAGES_EXTENSION"] = ".md"
|
||||
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS
|
||||
app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG
|
||||
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
|
||||
app.config["LANGUAGES"] = {
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"fr": "Français",
|
||||
"id": "Bahasa Indonesia",
|
||||
"ms": "Bahasa Melayu",
|
||||
"ru": "русский язык",
|
||||
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = ["fenced_code", "tables", "codehilite", 'toc']
|
||||
app.config["FLATPAGES_EXTENSION_CONFIG"] = {
|
||||
"fenced_code": {},
|
||||
"tables": {},
|
||||
"codehilite": {
|
||||
"guess_lang": False,
|
||||
}
|
||||
}
|
||||
|
||||
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
||||
|
||||
r = redis.Redis.from_url(app.config["REDIS_URL"])
|
||||
|
||||
menu.Menu(app=app)
|
||||
github = GitHub(app)
|
||||
csrf = CSRFProtect(app)
|
||||
mail = Mail(app)
|
||||
pages = FlatPages(app)
|
||||
babel = Babel(app)
|
||||
gravatar = Gravatar(app,
|
||||
size=64,
|
||||
size=58,
|
||||
rating="g",
|
||||
default="retro",
|
||||
default="mp",
|
||||
force_default=False,
|
||||
force_lower=False,
|
||||
use_ssl=True,
|
||||
base_url=None)
|
||||
init_markdown(app)
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "users.login"
|
||||
|
||||
|
||||
from .sass import init_app as sass
|
||||
from .sass import sass
|
||||
sass(app)
|
||||
|
||||
|
||||
|
@ -75,8 +69,14 @@ if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
|
|||
app.logger.addHandler(build_handler(app))
|
||||
|
||||
|
||||
from . import models, template_filters
|
||||
from app.utils.markdown import init_app
|
||||
init_app(app)
|
||||
|
||||
# @babel.localeselector
|
||||
# def get_locale():
|
||||
# return request.accept_languages.best_match(app.config["LANGUAGES"].keys())
|
||||
|
||||
from . import models, template_filters
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
|
@ -90,6 +90,7 @@ create_blueprints(app)
|
|||
def send_upload(path):
|
||||
return send_from_directory(app.config["UPLOAD_DIR"], path)
|
||||
|
||||
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { "path": "help" })
|
||||
@app.route("/<path:path>/")
|
||||
def flatpage(path):
|
||||
page = pages.get_or_404(path)
|
||||
|
@ -100,15 +101,14 @@ def flatpage(path):
|
|||
def check_for_ban():
|
||||
if current_user.is_authenticated:
|
||||
if current_user.rank == models.UserRank.BANNED:
|
||||
flash(gettext("You have been banned."), "danger")
|
||||
flash("You have been banned.", "danger")
|
||||
logout_user()
|
||||
return redirect(url_for("users.login"))
|
||||
elif current_user.rank == models.UserRank.NOT_JOINED:
|
||||
current_user.rank = models.UserRank.MEMBER
|
||||
models.db.session.commit()
|
||||
|
||||
from .utils import clearNotifications, is_safe_url
|
||||
|
||||
from .utils import clearNotifications
|
||||
|
||||
@app.before_request
|
||||
def check_for_notifications():
|
||||
|
@ -118,55 +118,3 @@ def check_for_notifications():
|
|||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template("404.html"), 404
|
||||
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
if not request:
|
||||
return None
|
||||
|
||||
locales = app.config["LANGUAGES"].keys()
|
||||
|
||||
if current_user.is_authenticated and current_user.locale in locales:
|
||||
return current_user.locale
|
||||
|
||||
locale = request.cookies.get("locale")
|
||||
if locale not in locales:
|
||||
locale = request.accept_languages.best_match(locales)
|
||||
|
||||
if locale and current_user.is_authenticated:
|
||||
new_session = models.db.create_session({})()
|
||||
new_session.query(models.User) \
|
||||
.filter(models.User.username == current_user.username) \
|
||||
.update({ "locale": locale })
|
||||
new_session.commit()
|
||||
new_session.close()
|
||||
|
||||
return locale
|
||||
|
||||
|
||||
|
||||
@app.route("/set-locale/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def set_locale():
|
||||
locale = request.form.get("locale")
|
||||
if locale not in app.config["LANGUAGES"].keys():
|
||||
flash("Unknown locale {}".format(locale), "danger")
|
||||
locale = None
|
||||
|
||||
next_url = request.form.get("r")
|
||||
if next_url and is_safe_url(next_url):
|
||||
resp = make_response(redirect(next_url))
|
||||
else:
|
||||
resp = make_response(redirect(url_for("homepage.home")))
|
||||
|
||||
if locale:
|
||||
expire_date = datetime.datetime.now()
|
||||
expire_date = expire_date + datetime.timedelta(days=5*365)
|
||||
resp.set_cookie("locale", locale, expires=expire_date)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.locale = locale
|
||||
models.db.session.commit()
|
||||
|
||||
return resp
|
||||
|
|
|
@ -1,338 +0,0 @@
|
|||
# ContentDB
|
||||
# Copyright (C) 2018-21 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/>.
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from celery import group
|
||||
from flask import redirect, url_for, flash, current_app, jsonify
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.logic.game_support import GameSupportResolver
|
||||
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, PackageGameSupport
|
||||
from app.tasks.emails import send_pending_digests
|
||||
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
|
||||
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
|
||||
from app.utils import addNotification, get_system_user
|
||||
from app.utils.image import get_image_size
|
||||
|
||||
actions = {}
|
||||
|
||||
|
||||
def action(title: str):
|
||||
def func(f):
|
||||
name = f.__name__
|
||||
actions[name] = {
|
||||
"title": title,
|
||||
"func": f,
|
||||
}
|
||||
|
||||
return f
|
||||
|
||||
return func
|
||||
|
||||
|
||||
@action("Delete stuck releases")
|
||||
def del_stuck_releases():
|
||||
PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Check ZIP releases")
|
||||
def check_releases():
|
||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||
|
||||
tasks = []
|
||||
for release in releases:
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("Check the first release of all packages")
|
||||
def reimport_packages():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("Import forum topic list")
|
||||
def import_topic_list():
|
||||
task = importTopicList.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
|
||||
|
||||
|
||||
@action("Check all forum accounts")
|
||||
def check_all_forum_accounts():
|
||||
task = checkAllForumAccounts.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Import screenshots")
|
||||
def import_screenshots():
|
||||
packages = Package.query \
|
||||
.filter(Package.state != PackageState.DELETED) \
|
||||
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
|
||||
.filter(PackageScreenshot.id.is_(None)) \
|
||||
.all()
|
||||
for package in packages:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Remove unused uploads")
|
||||
def clean_uploads():
|
||||
upload_dir = current_app.config['UPLOAD_DIR']
|
||||
|
||||
(_, _, filenames) = next(os.walk(upload_dir))
|
||||
existing_uploads = set(filenames)
|
||||
|
||||
if len(existing_uploads) != 0:
|
||||
def get_filenames_from_column(column):
|
||||
results = db.session.query(column).filter(column.isnot(None), column != "").all()
|
||||
return set([os.path.basename(x[0]) for x in results])
|
||||
|
||||
release_urls = get_filenames_from_column(PackageRelease.url)
|
||||
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
|
||||
|
||||
db_urls = release_urls.union(screenshot_urls)
|
||||
unreachable = existing_uploads.difference(db_urls)
|
||||
|
||||
import sys
|
||||
print("On Disk: ", existing_uploads, file=sys.stderr)
|
||||
print("In DB: ", db_urls, file=sys.stderr)
|
||||
print("Unreachable: ", unreachable, file=sys.stderr)
|
||||
|
||||
for filename in unreachable:
|
||||
os.remove(os.path.join(upload_dir, filename))
|
||||
|
||||
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
|
||||
else:
|
||||
flash("No downloads to create", "danger")
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Delete unused metapackages")
|
||||
def del_meta_packages():
|
||||
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
|
||||
count = query.count()
|
||||
query.delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted " + str(count) + " unused meta packages", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Delete removed packages")
|
||||
def del_removed_packages():
|
||||
query = Package.query.filter_by(state=PackageState.DELETED)
|
||||
count = query.count()
|
||||
for pkg in query.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Run update configs")
|
||||
def run_update_config():
|
||||
check_for_updates.delay()
|
||||
|
||||
flash("Started update configs", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
def _package_list(packages: List[str]):
|
||||
# Who needs translations?
|
||||
if len(packages) >= 3:
|
||||
packages[len(packages) - 1] = "and " + packages[len(packages) - 1]
|
||||
packages_list = ", ".join(packages)
|
||||
else:
|
||||
packages_list = " and ".join(packages)
|
||||
return packages_list
|
||||
|
||||
|
||||
@action("Send WIP package notification")
|
||||
def remind_wip():
|
||||
users = User.query.filter(User.packages.any(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
Package.author_id == user.id,
|
||||
or_(Package.state == PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
havent = "haven't" if len(packages) > 1 else "hasn't"
|
||||
if len(packages_list) + 54 > 100:
|
||||
packages_list = packages_list[0:(100-54-1)] + "…"
|
||||
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"Did you forget? {packages_list} {havent} been submitted for review yet",
|
||||
url_for('todo.view_user', username=user.username))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send outdated package notification")
|
||||
def remind_outdated():
|
||||
users = User.query.filter(User.maintained_packages.any(
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
Package.maintainers.any(User.id==user.id),
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"The following packages may be outdated: {packages_list}",
|
||||
url_for('todo.view_user', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Import licenses from SPDX")
|
||||
def import_licenses():
|
||||
renames = {
|
||||
"GPLv2": "GPL-2.0-only",
|
||||
"GPLv3": "GPL-3.0-only",
|
||||
"AGPLv2": "AGPL-2.0-only",
|
||||
"AGPLv3": "AGPL-3.0-only",
|
||||
"LGPLv2.1": "LGPL-2.1-only",
|
||||
"LGPLv3": "LGPL-3.0-only",
|
||||
"Apache 2.0": "Apache-2.0",
|
||||
"BSD 2-Clause / FreeBSD": "BSD-2-Clause-FreeBSD",
|
||||
"BSD 3-Clause": "BSD-3-Clause",
|
||||
"CC0": "CC0-1.0",
|
||||
"CC BY 3.0": "CC-BY-3.0",
|
||||
"CC BY 4.0": "CC-BY-4.0",
|
||||
"CC BY-NC-SA 3.0": "CC-BY-NC-SA-3.0",
|
||||
"CC BY-SA 3.0": "CC-BY-SA-3.0",
|
||||
"CC BY-SA 4.0": "CC-BY-SA-4.0",
|
||||
"NPOSLv3": "NPOSL-3.0",
|
||||
"MPL 2.0": "MPL-2.0",
|
||||
"EUPLv1.2": "EUPL-1.2",
|
||||
"SIL Open Font License v1.1": "OFL-1.1",
|
||||
}
|
||||
|
||||
for old_name, new_name in renames.items():
|
||||
License.query.filter_by(name=old_name).update({ "name": new_name })
|
||||
|
||||
r = requests.get(
|
||||
"https://raw.githubusercontent.com/spdx/license-list-data/master/json/licenses.json")
|
||||
licenses = r.json()["licenses"]
|
||||
|
||||
existing_licenses = {}
|
||||
for license in License.query.all():
|
||||
assert license.name not in renames.keys()
|
||||
existing_licenses[license.name.lower()] = license
|
||||
|
||||
for license in licenses:
|
||||
obj = existing_licenses.get(license["licenseId"].lower())
|
||||
if obj:
|
||||
obj.url = license["reference"]
|
||||
elif license.get("isOsiApproved") and license.get("isFsfLibre") and \
|
||||
not license["isDeprecatedLicenseId"]:
|
||||
obj = License(license["licenseId"], True, license["reference"])
|
||||
db.session.add(obj)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Delete inactive users")
|
||||
def delete_inactive_users():
|
||||
users = User.query.filter(User.is_active == False, User.packages.is_(None), User.forum_topics.is_(None),
|
||||
User.rank == UserRank.NOT_JOINED).all()
|
||||
for user in users:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send Video URL notification")
|
||||
def remind_video_url():
|
||||
users = User.query.filter(User.maintained_packages.any(
|
||||
and_(Package.video_url.is_(None), Package.type==PackageType.GAME, Package.state==PackageState.APPROVED)))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
or_(Package.author==user, Package.maintainers.any(User.id==user.id)),
|
||||
Package.video_url.is_(None),
|
||||
Package.type == PackageType.GAME,
|
||||
Package.state == PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"You should add a video to {packages_list}",
|
||||
url_for('users.profile', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Update screenshot sizes")
|
||||
def update_screenshot_sizes():
|
||||
import sys
|
||||
|
||||
for screenshot in PackageScreenshot.query.all():
|
||||
width, height = get_image_size(screenshot.file_path)
|
||||
print(f"{screenshot.url}: {width}, {height}", file=sys.stderr)
|
||||
screenshot.width = width
|
||||
screenshot.height = height
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Detect game support")
|
||||
def detect_game_support():
|
||||
resolver = GameSupportResolver()
|
||||
resolver.update_all()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send pending notif digests")
|
||||
def do_send_pending_digests():
|
||||
send_pending_digests.delay()
|
|
@ -14,15 +14,21 @@
|
|||
# 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 redirect, render_template, url_for, request, flash
|
||||
|
||||
import os
|
||||
|
||||
from celery import group
|
||||
from flask import *
|
||||
from flask_login import current_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms import *
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.utils import rank_required, addAuditLog, addNotification, get_system_user
|
||||
|
||||
from app.models import *
|
||||
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
|
||||
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
|
||||
from app.utils import rank_required, addAuditLog, addNotification
|
||||
from . import bp
|
||||
from .actions import actions
|
||||
from ...models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType
|
||||
|
||||
|
||||
@bp.route("/admin/", methods=["GET", "POST"])
|
||||
|
@ -31,7 +37,63 @@ def admin_page():
|
|||
if request.method == "POST":
|
||||
action = request.form["action"]
|
||||
|
||||
if action == "restore":
|
||||
if action == "delstuckreleases":
|
||||
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action == "checkreleases":
|
||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||
|
||||
tasks = []
|
||||
for release in releases:
|
||||
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
tasks.append(checkZipRelease.s(release.id, zippath))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
elif action == "reimportpackages":
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state!=PackageState.DELETED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
tasks.append(checkZipRelease.s(release.id, zippath))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
elif action == "importmodlist":
|
||||
task = importTopicList.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
|
||||
|
||||
elif action == "checkusers":
|
||||
task = checkAllForumAccounts.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||
|
||||
elif action == "importscreenshots":
|
||||
packages = Package.query \
|
||||
.filter(Package.state!=PackageState.DELETED) \
|
||||
.outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \
|
||||
.filter(PackageScreenshot.id==None) \
|
||||
.all()
|
||||
for package in packages:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action == "restore":
|
||||
package = Package.query.get(request.form["package"])
|
||||
if package is None:
|
||||
flash("Unknown package", "danger")
|
||||
|
@ -40,17 +102,93 @@ def admin_page():
|
|||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action in actions:
|
||||
ret = actions[action]["func"]()
|
||||
if ret:
|
||||
return ret
|
||||
elif action == "recalcscores":
|
||||
for p in Package.query.all():
|
||||
p.recalcScore()
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action == "cleanuploads":
|
||||
upload_dir = app.config['UPLOAD_DIR']
|
||||
|
||||
(_, _, filenames) = next(os.walk(upload_dir))
|
||||
existing_uploads = set(filenames)
|
||||
|
||||
if len(existing_uploads) != 0:
|
||||
def getURLsFromDB(column):
|
||||
results = db.session.query(column).filter(column != None, column != "").all()
|
||||
return set([os.path.basename(x[0]) for x in results])
|
||||
|
||||
release_urls = getURLsFromDB(PackageRelease.url)
|
||||
screenshot_urls = getURLsFromDB(PackageScreenshot.url)
|
||||
|
||||
db_urls = release_urls.union(screenshot_urls)
|
||||
unreachable = existing_uploads.difference(db_urls)
|
||||
|
||||
import sys
|
||||
print("On Disk: ", existing_uploads, file=sys.stderr)
|
||||
print("In DB: ", db_urls, file=sys.stderr)
|
||||
print("Unreachable: ", unreachable, file=sys.stderr)
|
||||
|
||||
for filename in unreachable:
|
||||
os.remove(os.path.join(upload_dir, filename))
|
||||
|
||||
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
|
||||
else:
|
||||
flash("No downloads to create", "danger")
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action == "delmetapackages":
|
||||
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
|
||||
count = query.count()
|
||||
query.delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted " + str(count) + " unused meta packages", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action == "delremovedpackages":
|
||||
query = Package.query.filter_by(state=PackageState.DELETED)
|
||||
count = query.count()
|
||||
for pkg in query.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action == "addupdateconfig":
|
||||
added = 0
|
||||
for pkg in Package.query.filter(Package.repo != None, Package.releases.any(), Package.update_config == None).all():
|
||||
pkg.update_config = PackageUpdateConfig()
|
||||
pkg.update_config.auto_created = True
|
||||
|
||||
release: PackageRelease = pkg.releases.first()
|
||||
if release and release.commit_hash:
|
||||
pkg.update_config.last_commit = release.commit_hash
|
||||
|
||||
db.session.add(pkg.update_config)
|
||||
added += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash("Added {} update configs".format(added), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action == "runupdateconfig":
|
||||
check_for_updates.delay()
|
||||
|
||||
flash("Started update configs", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
else:
|
||||
flash("Unknown action: " + action, "danger")
|
||||
|
||||
deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
|
||||
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
|
||||
|
||||
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).all()
|
||||
return render_template("admin/list.html", deleted_packages=deleted_packages)
|
||||
|
||||
class SwitchUserForm(FlaskForm):
|
||||
username = StringField("Username")
|
||||
|
@ -70,13 +208,14 @@ def switch_user():
|
|||
else:
|
||||
flash("Unable to login as user", "danger")
|
||||
|
||||
|
||||
# Process GET or invalid POST
|
||||
return render_template("admin/switch_user.html", form=form)
|
||||
|
||||
|
||||
class SendNotificationForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(1, 300)])
|
||||
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
|
||||
title = StringField("Title", [InputRequired(), Length(1, 300)])
|
||||
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
|
||||
submit = SubmitField("Send")
|
||||
|
||||
|
||||
|
@ -86,45 +225,12 @@ def send_bulk_notification():
|
|||
form = SendNotificationForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
|
||||
"Sent bulk notification", None, None, form.title.data)
|
||||
|
||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
|
||||
addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
|
||||
addNotification(users, current_user, NotificationType.OTHER, form.title.data, form.url.data, None)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
return render_template("admin/send_bulk_notification.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/admin/restore/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.EDITOR)
|
||||
def restore():
|
||||
if request.method == "POST":
|
||||
target = request.form["submit"]
|
||||
if "Review" in target:
|
||||
target = PackageState.READY_FOR_REVIEW
|
||||
elif "Changes" in target:
|
||||
target = PackageState.CHANGES_NEEDED
|
||||
else:
|
||||
target = PackageState.WIP
|
||||
|
||||
package = Package.query.get(request.form["package"])
|
||||
if package is None:
|
||||
flash("Unknown package", "danger")
|
||||
else:
|
||||
package.state = target
|
||||
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
|
||||
package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
deleted_packages = Package.query \
|
||||
.filter(Package.state == PackageState.DELETED) \
|
||||
.join(Package.author) \
|
||||
.order_by(db.asc(User.username), db.asc(Package.name)) \
|
||||
.all()
|
||||
|
||||
return render_template("admin/restore.html", deleted_packages=deleted_packages)
|
||||
|
|
|
@ -39,8 +39,8 @@ def audit():
|
|||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
|
||||
|
||||
|
||||
@bp.route("/admin/audit/<int:id_>/")
|
||||
@bp.route("/admin/audit/<int:id>/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def audit_view(id_):
|
||||
entry = AuditLogEntry.query.get(id_)
|
||||
def audit_view(id):
|
||||
entry = AuditLogEntry.query.get(id)
|
||||
return render_template("admin/audit_view.html", entry=entry)
|
||||
|
|
|
@ -14,17 +14,18 @@
|
|||
# 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 request, abort, url_for, redirect, render_template, flash
|
||||
|
||||
from flask import *
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import TextAreaField, SubmitField, StringField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.markdown import render_markdown
|
||||
from app.utils.markdown import render_markdown
|
||||
from app.models import *
|
||||
from app.tasks.emails import send_user_email
|
||||
from app.utils import rank_required, addAuditLog
|
||||
from . import bp
|
||||
from ...models import UserRank, User, AuditSeverity
|
||||
|
||||
|
||||
class SendEmailForm(FlaskForm):
|
||||
|
@ -54,7 +55,7 @@ def send_single_email():
|
|||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
task = send_user_email.delay(user.email, user.locale or "en",form.subject.data, text, html)
|
||||
task = send_user_email.delay(user.email, form.subject.data, text, html)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
||||
|
||||
return render_template("admin/send_email.html", form=form, user=user)
|
||||
|
@ -66,12 +67,12 @@ def send_bulk_email():
|
|||
form = SendEmailForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
|
||||
"Sent bulk email", None, None, form.text.data)
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
for user in User.query.filter(User.email.isnot(None)).all():
|
||||
send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
|
||||
for user in User.query.filter(User.email != None).all():
|
||||
send_user_email.delay(user.email, form.subject.data, text, html)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
|
|
@ -15,14 +15,14 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask import *
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.utils import rank_required, nonEmptyOrNone
|
||||
from app.models import *
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, License, db
|
||||
|
||||
|
||||
@bp.route("/licenses/")
|
||||
|
@ -30,13 +30,10 @@ from ...models import UserRank, License, db
|
|||
def license_list():
|
||||
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
|
||||
|
||||
|
||||
class LicenseForm(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
||||
is_foss = BooleanField("Is FOSS")
|
||||
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
name = StringField("Name", [InputRequired(), Length(3,100)])
|
||||
is_foss = BooleanField("Is FOSS")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
@bp.route("/licenses/new/", methods=["GET", "POST"])
|
||||
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
|
||||
|
|
|
@ -15,14 +15,14 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request
|
||||
from flask import *
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import *
|
||||
from . import bp
|
||||
from ...models import Permission, Tag, db
|
||||
|
||||
|
||||
@bp.route("/tags/")
|
||||
|
@ -40,14 +40,11 @@ def tag_list():
|
|||
|
||||
return render_template("admin/tags/list.html", tags=query.all())
|
||||
|
||||
|
||||
class TagForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
||||
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||
description = TextAreaField("Description", [Optional(), Length(0, 500)])
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
is_protected = BooleanField("Is Protected")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
@bp.route("/tags/new/", methods=["GET", "POST"])
|
||||
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
|
||||
|
@ -62,16 +59,14 @@ def create_edit_tag(name=None):
|
|||
if not Permission.checkPerm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
|
||||
abort(403)
|
||||
|
||||
form = TagForm( obj=tag)
|
||||
form = TagForm(formdata=request.form, obj=tag)
|
||||
if form.validate_on_submit():
|
||||
if tag is None:
|
||||
tag = Tag(form.title.data)
|
||||
tag.description = form.description.data
|
||||
tag.is_protected = form.is_protected.data
|
||||
db.session.add(tag)
|
||||
else:
|
||||
form.populate_obj(tag)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if Permission.EDIT_TAGS.check(current_user):
|
||||
|
|
|
@ -15,14 +15,14 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask import *
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, MinetestRelease, db
|
||||
|
||||
|
||||
@bp.route("/versions/")
|
||||
|
@ -30,12 +30,10 @@ from ...models import UserRank, MinetestRelease, db
|
|||
def version_list():
|
||||
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
|
||||
|
||||
|
||||
class VersionForm(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
||||
name = StringField("Name", [InputRequired(), Length(3,100)])
|
||||
protocol = IntegerField("Protocol")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
submit = SubmitField("Save")
|
||||
|
||||
@bp.route("/versions/new/", methods=["GET", "POST"])
|
||||
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
|
||||
|
|
|
@ -15,14 +15,14 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask import *
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, ContentWarning, db
|
||||
|
||||
|
||||
@bp.route("/admin/warnings/")
|
||||
|
@ -30,14 +30,11 @@ from ...models import UserRank, ContentWarning, db
|
|||
def warning_list():
|
||||
return render_template("admin/warnings/list.html", warnings=ContentWarning.query.order_by(db.asc(ContentWarning.title)).all())
|
||||
|
||||
|
||||
class WarningForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
||||
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||
description = TextAreaField("Description", [Optional(), Length(0, 500)])
|
||||
name = StringField("Name", [Optional(), Length(1, 20),
|
||||
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
@bp.route("/admin/warnings/new/", methods=["GET", "POST"])
|
||||
@bp.route("/admin/warnings/<name>/edit/", methods=["GET", "POST"])
|
||||
|
|
|
@ -14,41 +14,21 @@
|
|||
# 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/>.
|
||||
|
||||
import math
|
||||
from typing import List
|
||||
|
||||
import flask_sqlalchemy
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import csrf
|
||||
from app.markdown import render_markdown
|
||||
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
|
||||
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
|
||||
from app.utils.markdown import render_markdown
|
||||
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
|
||||
from app.utils import is_package_page, get_int_or_abort
|
||||
from . import bp
|
||||
from .auth import is_api_authd
|
||||
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
|
||||
api_order_screenshots, api_edit_package, api_set_cover_image
|
||||
from functools import wraps
|
||||
|
||||
|
||||
def cors_allowed(f):
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
res = f(*args, **kwargs)
|
||||
res.headers["Access-Control-Allow-Origin"] = "*"
|
||||
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
return res
|
||||
return inner
|
||||
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package
|
||||
|
||||
|
||||
@bp.route("/api/packages/")
|
||||
@cors_allowed
|
||||
def packages():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
@ -64,7 +44,6 @@ def packages():
|
|||
|
||||
@bp.route("/api/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package(package):
|
||||
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
|
||||
|
||||
|
@ -73,7 +52,6 @@ def package(package):
|
|||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def edit_package(token, package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
@ -81,7 +59,7 @@ def edit_package(token, package):
|
|||
return api_edit_package(token, package, request.json)
|
||||
|
||||
|
||||
def resolve_package_deps(out, package, only_hard, depth=1):
|
||||
def resolve_package_deps(out, package, only_hard):
|
||||
id = package.getId()
|
||||
if id in out:
|
||||
return
|
||||
|
@ -89,9 +67,6 @@ def resolve_package_deps(out, package, only_hard, depth=1):
|
|||
ret = []
|
||||
out[id] = ret
|
||||
|
||||
if package.type != PackageType.MOD:
|
||||
return
|
||||
|
||||
for dep in package.dependencies:
|
||||
if only_hard and dep.optional:
|
||||
continue
|
||||
|
@ -99,16 +74,12 @@ def resolve_package_deps(out, package, only_hard, depth=1):
|
|||
if dep.package:
|
||||
name = dep.package.name
|
||||
fulfilled_by = [ dep.package.getId() ]
|
||||
resolve_package_deps(out, dep.package, only_hard, depth)
|
||||
resolve_package_deps(out, dep.package, only_hard)
|
||||
|
||||
elif dep.meta_package:
|
||||
name = dep.meta_package.name
|
||||
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
|
||||
|
||||
if depth == 1 and not dep.optional:
|
||||
most_likely = next((pkg for pkg in dep.meta_package.packages if pkg.type == PackageType.MOD), None)
|
||||
if most_likely:
|
||||
resolve_package_deps(out, most_likely, only_hard, depth + 1)
|
||||
# TODO: resolve most likely candidate
|
||||
|
||||
else:
|
||||
raise Exception("Malformed dependency")
|
||||
|
@ -122,7 +93,6 @@ def resolve_package_deps(out, package, only_hard, depth=1):
|
|||
|
||||
@bp.route("/api/packages/<author>/<name>/dependencies/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package_dependencies(package):
|
||||
only_hard = request.args.get("only_hard")
|
||||
|
||||
|
@ -133,7 +103,6 @@ def package_dependencies(package):
|
|||
|
||||
|
||||
@bp.route("/api/topics/")
|
||||
@cors_allowed
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildTopicQuery(show_added=True)
|
||||
|
@ -160,7 +129,6 @@ def topic_set_discard():
|
|||
|
||||
@bp.route("/api/whoami/")
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def whoami(token):
|
||||
if token is None:
|
||||
return jsonify({ "is_authenticated": False, "username": None })
|
||||
|
@ -175,7 +143,6 @@ def markdown():
|
|||
|
||||
|
||||
@bp.route("/api/releases/")
|
||||
@cors_allowed
|
||||
def list_all_releases():
|
||||
query = PackageRelease.query.filter_by(approved=True) \
|
||||
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
|
||||
|
@ -199,7 +166,6 @@ def list_all_releases():
|
|||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def list_releases(package):
|
||||
return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ])
|
||||
|
||||
|
@ -208,7 +174,6 @@ def list_releases(package):
|
|||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def create_release(token, package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
@ -242,7 +207,6 @@ def create_release(token, package):
|
|||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def release(package: Package, id: int):
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
|
@ -255,7 +219,6 @@ def release(package: Package, id: int):
|
|||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def delete_release(token: APIToken, package: Package, id: int):
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
|
@ -278,7 +241,6 @@ def delete_release(token: APIToken, package: Package, id: int):
|
|||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def list_screenshots(package):
|
||||
screenshots = package.screenshots.all()
|
||||
return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots])
|
||||
|
@ -288,7 +250,6 @@ def list_screenshots(package):
|
|||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def create_screenshot(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
@ -304,12 +265,11 @@ def create_screenshot(token: APIToken, package: Package):
|
|||
if file is None:
|
||||
error(400, "Missing 'file' in multipart body")
|
||||
|
||||
return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
|
||||
return api_create_screenshot(token, package, data["title"], file)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def screenshot(package, id):
|
||||
ss = PackageScreenshot.query.get(id)
|
||||
if ss is None or ss.package != package:
|
||||
|
@ -322,7 +282,6 @@ def screenshot(package, id):
|
|||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def delete_screenshot(token: APIToken, package: Package, id: int):
|
||||
ss = PackageScreenshot.query.get(id)
|
||||
if ss is None or ss.package != package:
|
||||
|
@ -351,13 +310,12 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
|
|||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def order_screenshots(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
error(403, "You do not have the permission to delete screenshots")
|
||||
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
@ -369,71 +327,7 @@ def order_screenshots(token: APIToken, package: Package):
|
|||
return api_order_screenshots(token, package, request.json)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/cover-image/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def set_cover_image(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
json = request.json
|
||||
if json is None or not isinstance(json, dict) or "cover_image" not in json:
|
||||
error(400, "Expected body to be an object with cover_image as a key")
|
||||
|
||||
return api_set_cover_image(token, package, request.json["cover_image"])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/reviews/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def list_reviews(package):
|
||||
reviews = package.reviews
|
||||
return jsonify([review.getAsDictionary() for review in reviews])
|
||||
|
||||
|
||||
@bp.route("/api/reviews/")
|
||||
@cors_allowed
|
||||
def list_all_reviews():
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(get_int_or_abort(request.args.get("n"), 100), 100)
|
||||
|
||||
query = PackageReview.query
|
||||
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
|
||||
|
||||
if request.args.get("author"):
|
||||
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
|
||||
|
||||
if request.args.get("is_positive"):
|
||||
query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive")))
|
||||
|
||||
q = request.args.get("q")
|
||||
if q:
|
||||
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
|
||||
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
|
||||
return jsonify({
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
"page_count": math.ceil(pagination.total / pagination.per_page),
|
||||
"total": pagination.total,
|
||||
"urls": {
|
||||
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
|
||||
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
|
||||
},
|
||||
"items": [review.getAsDictionary(True) for review in pagination.items],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/scores/")
|
||||
@cors_allowed
|
||||
def package_scores():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
@ -443,32 +337,26 @@ def package_scores():
|
|||
|
||||
|
||||
@bp.route("/api/tags/")
|
||||
@cors_allowed
|
||||
def tags():
|
||||
return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/content_warnings/")
|
||||
@cors_allowed
|
||||
def content_warnings():
|
||||
return jsonify([warning.getAsDictionary() for warning in ContentWarning.query.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/licenses/")
|
||||
@cors_allowed
|
||||
def licenses():
|
||||
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
|
||||
for license in License.query.order_by(db.asc(License.name)).all() ])
|
||||
|
||||
|
||||
@bp.route("/api/homepage/")
|
||||
@cors_allowed
|
||||
def homepage():
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
featured = query.filter(Package.tags.any(name="featured")).order_by(
|
||||
func.random()).limit(6).all()
|
||||
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
|
||||
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
|
||||
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
|
||||
|
@ -485,44 +373,22 @@ def homepage():
|
|||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||
|
||||
def mapPackages(packages: List[Package]):
|
||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
def mapPackages(packages):
|
||||
return [pkg.getAsDictionaryKey() for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
return {
|
||||
"count": count,
|
||||
"downloads": downloads,
|
||||
"featured": mapPackages(featured),
|
||||
"new": mapPackages(new),
|
||||
"updated": mapPackages(updated),
|
||||
"pop_mod": mapPackages(pop_mod),
|
||||
"pop_txp": mapPackages(pop_txp),
|
||||
"pop_game": mapPackages(pop_gam),
|
||||
"high_reviewed": mapPackages(high_reviewed)
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/welcome/v1/")
|
||||
@cors_allowed
|
||||
def welcome_v1():
|
||||
featured = Package.query \
|
||||
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
|
||||
Package.tags.any(name="featured")) \
|
||||
.order_by(func.random()) \
|
||||
.limit(5).all()
|
||||
|
||||
mtg = Package.query.filter(Package.author.has(username="Minetest"), Package.name == "minetest_game").one()
|
||||
featured.insert(2, mtg)
|
||||
|
||||
def map_packages(packages: List[Package]):
|
||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
"featured": map_packages(featured),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/api/minetest_versions/")
|
||||
@cors_allowed
|
||||
def versions():
|
||||
protocol_version = request.args.get("protocol_version")
|
||||
engine_version = request.args.get("engine_version")
|
||||
|
@ -535,35 +401,3 @@ def versions():
|
|||
|
||||
return jsonify([rel.getAsDictionary() \
|
||||
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
|
||||
|
||||
|
||||
@bp.route("/api/dependencies/")
|
||||
@cors_allowed
|
||||
def all_deps():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
def format_pkg(pkg: Package):
|
||||
return {
|
||||
"type": pkg.type.toName(),
|
||||
"author": pkg.author.username,
|
||||
"name": pkg.name,
|
||||
"provides": [x.name for x in pkg.provides],
|
||||
"depends": [str(x) for x in pkg.dependencies if not x.optional],
|
||||
"optional_depends": [str(x) for x in pkg.dependencies if x.optional],
|
||||
}
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(get_int_or_abort(request.args.get("n"), 100), 300)
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
|
||||
return jsonify({
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
"page_count": math.ceil(pagination.total / pagination.per_page),
|
||||
"total": pagination.total,
|
||||
"urls": {
|
||||
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
|
||||
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
|
||||
},
|
||||
"items": [format_pkg(pkg) for pkg in pagination.items],
|
||||
})
|
||||
|
|
|
@ -19,7 +19,7 @@ from flask import jsonify, abort, make_response, url_for, current_app
|
|||
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
|
||||
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
|
||||
|
||||
|
||||
|
@ -69,13 +69,13 @@ def api_create_zip_release(token: APIToken, package: Package, title: str, file,
|
|||
})
|
||||
|
||||
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, reason="API"):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
|
||||
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
|
@ -94,24 +94,13 @@ def api_order_screenshots(token: APIToken, package: Package, order: [any]):
|
|||
})
|
||||
|
||||
|
||||
def api_set_cover_image(token: APIToken, package: Package, cover_image):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
guard(do_set_cover_image)(token.owner, package, cover_image)
|
||||
|
||||
return jsonify({
|
||||
"success": True
|
||||
})
|
||||
|
||||
|
||||
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
package = guard(do_edit_package)(token.owner, package, False, False, data, reason)
|
||||
package = guard(do_edit_package)(token.owner, package, False, data, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
|
|
|
@ -16,11 +16,10 @@
|
|||
|
||||
|
||||
from flask import render_template, redirect, request, session, url_for, abort
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import db, User, APIToken, Package, Permission
|
||||
|
@ -30,10 +29,10 @@ from ..users.settings import get_setting_tabs
|
|||
|
||||
|
||||
class CreateAPIToken(FlaskForm):
|
||||
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)])
|
||||
package = QuerySelectField(lazy_gettext("Limit to package"), allow_blank=True,
|
||||
name = StringField("Name", [InputRequired(), Length(1, 30)])
|
||||
package = QuerySelectField("Limit to package", allow_blank=True,
|
||||
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/user/tokens/")
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint
|
||||
from flask_babel import gettext
|
||||
|
||||
bp = Blueprint("github", __name__)
|
||||
|
||||
|
@ -43,7 +42,7 @@ def view_permissions():
|
|||
def callback(oauth_token):
|
||||
next_url = request.args.get("next")
|
||||
if oauth_token is None:
|
||||
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
|
||||
flash("Authorization failed [err=gh-oauth-login-failed]", "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
# Get Github username
|
||||
|
@ -59,28 +58,30 @@ def callback(oauth_token):
|
|||
if userByGithub is None:
|
||||
current_user.github_username = username
|
||||
db.session.commit()
|
||||
flash(gettext("Linked GitHub to account"), "success")
|
||||
flash("Linked github to account", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
else:
|
||||
flash(gettext("GitHub account is already associated with another user"), "danger")
|
||||
flash("Github account is already associated with another user", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# If not logged in, log in
|
||||
else:
|
||||
if userByGithub is None:
|
||||
flash(gettext("Unable to find an account for that GitHub user"), "danger")
|
||||
flash("Unable to find an account for that Github user", "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
elif login_user_set_active(userByGithub, remember=True):
|
||||
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
|
||||
url_for("users.profile", username=userByGithub.username))
|
||||
db.session.commit()
|
||||
|
||||
ret = login_user_set_active(userByGithub, remember=True)
|
||||
if ret is None:
|
||||
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
|
||||
if not current_user.password:
|
||||
return redirect(next_url or url_for("users.set_password", optional=True))
|
||||
else:
|
||||
return redirect(next_url or url_for("homepage.home"))
|
||||
else:
|
||||
flash("Authorization failed [err=gh-login-failed]", "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
|
||||
url_for("users.profile", username=userByGithub.username))
|
||||
db.session.commit()
|
||||
return ret
|
||||
|
||||
|
||||
@bp.route("/github/webhook/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
|
@ -137,23 +138,13 @@ def webhook():
|
|||
if branch not in [ "master", "main" ]:
|
||||
return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
|
||||
|
||||
elif event == "create":
|
||||
ref_type = json.get("ref_type")
|
||||
if ref_type != "tag":
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
|
||||
})
|
||||
|
||||
elif event == "create" and json["ref_type"] == "tag":
|
||||
ref = json["ref"]
|
||||
title = ref
|
||||
|
||||
elif event == "ping":
|
||||
return jsonify({ "success": True, "message": "Ping successful" })
|
||||
|
||||
else:
|
||||
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
|
||||
.format(event or "null"))
|
||||
return error(400, "Unsupported event. Only 'push', `create:tag`, and 'ping' are supported.")
|
||||
|
||||
#
|
||||
# Perform release
|
||||
|
|
|
@ -63,8 +63,7 @@ def webhook_impl():
|
|||
ref = json["ref"]
|
||||
title = ref.replace("refs/tags/", "")
|
||||
else:
|
||||
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
|
||||
.format(event or "null"))
|
||||
return error(400, "Unsupported event. Only 'push' and 'tag_push' are supported.")
|
||||
|
||||
#
|
||||
# Perform release
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
from flask import Blueprint, render_template, redirect
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint("homepage", __name__)
|
||||
|
||||
from app.models import *
|
||||
import flask_menu as menu
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@menu.register_menu(bp, ".", "Home")
|
||||
def home():
|
||||
def join(query):
|
||||
return query.options(
|
||||
|
@ -17,8 +18,6 @@ def home():
|
|||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
featured = query.filter(Package.tags.any(name="featured")).order_by(func.random()).limit(6).all()
|
||||
|
||||
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
|
||||
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
|
||||
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
|
||||
|
@ -40,5 +39,5 @@ def home():
|
|||
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
||||
.select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
|
||||
|
||||
return render_template("index.html", count=count, downloads=downloads, tags=tags, featured=featured,
|
||||
return render_template("index.html", count=count, downloads=downloads, tags=tags,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed, reviews=reviews)
|
||||
|
|
|
@ -53,11 +53,12 @@ def view(name):
|
|||
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
similar_topics = ForumTopic.query \
|
||||
.filter_by(name=name) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
similar_topics = None
|
||||
if mpackage.packages.filter_by(state=PackageState.APPROVED).count() == 0:
|
||||
similar_topics = ForumTopic.query \
|
||||
.filter_by(name=name) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
return render_template("metapackages/view.html", mpackage=mpackage,
|
||||
dependers=dependers, optional_dependers=optional_dependers,
|
||||
|
|
|
@ -50,9 +50,9 @@ def generate_metrics(full=False):
|
|||
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
|
||||
|
||||
ret = ""
|
||||
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages)
|
||||
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users)
|
||||
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads)
|
||||
ret += write_single_stat("contentdb_packages", "Total packages", "counter", packages)
|
||||
ret += write_single_stat("contentdb_users", "Number of registered users", "counter", users)
|
||||
ret += write_single_stat("contentdb_downloads", "Total downloads", "counter", downloads)
|
||||
|
||||
if full:
|
||||
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint
|
||||
from flask_babel import gettext
|
||||
|
||||
from app.models import User, Package, Permission
|
||||
|
||||
|
@ -29,40 +28,30 @@ def get_package_tabs(user: User, package: Package):
|
|||
return [
|
||||
{
|
||||
"id": "edit",
|
||||
"title": gettext("Edit Details"),
|
||||
"url": package.getURL("packages.create_edit")
|
||||
"title": "Edit Details",
|
||||
"url": package.getEditURL()
|
||||
},
|
||||
{
|
||||
"id": "releases",
|
||||
"title": gettext("Releases"),
|
||||
"url": package.getURL("packages.list_releases")
|
||||
"title": "Releases",
|
||||
"url": package.getReleaseListURL()
|
||||
},
|
||||
{
|
||||
"id": "screenshots",
|
||||
"title": gettext("Screenshots"),
|
||||
"url": package.getURL("packages.screenshots")
|
||||
"title": "Screenshots",
|
||||
"url": package.getEditScreenshotsURL()
|
||||
},
|
||||
{
|
||||
"id": "maintainers",
|
||||
"title": gettext("Maintainers"),
|
||||
"url": package.getURL("packages.edit_maintainers")
|
||||
},
|
||||
{
|
||||
"id": "audit",
|
||||
"title": gettext("Audit Log"),
|
||||
"url": package.getURL("packages.audit")
|
||||
},
|
||||
{
|
||||
"id": "share",
|
||||
"title": gettext("Share and Badges"),
|
||||
"url": package.getURL("packages.share")
|
||||
"title": "Maintainers",
|
||||
"url": package.getEditMaintainersURL()
|
||||
},
|
||||
{
|
||||
"id": "remove",
|
||||
"title": gettext("Remove"),
|
||||
"url": package.getURL("packages.remove")
|
||||
"title": "Remove",
|
||||
"url": package.getRemoveURL()
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
from . import packages, screenshots, releases, reviews, game_hub
|
||||
from . import packages, screenshots, releases, reviews
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
# ContentDB
|
||||
# Copyright (C) 2022 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, abort
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from . import bp
|
||||
from app.utils import is_package_page
|
||||
from ...models import Package, PackageType, PackageState, db, PackageRelease
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/hub/")
|
||||
@is_package_page
|
||||
def game_hub(package: Package):
|
||||
if package.type != PackageType.GAME:
|
||||
abort(404)
|
||||
|
||||
def join(query):
|
||||
return query.options(
|
||||
joinedload(Package.license),
|
||||
joinedload(Package.media_license))
|
||||
|
||||
query = Package.query.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
|
||||
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
|
||||
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
|
||||
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
|
||||
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
|
||||
.filter(Package.reviews.any()).limit(4).all()
|
||||
|
||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
||||
.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED) \
|
||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
||||
.limit(20).all()
|
||||
updated = updated[:4]
|
||||
|
||||
return render_template("packages/game_hub.html", package=package, count=count,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam,
|
||||
high_reviewed=high_reviewed)
|
|
@ -17,27 +17,30 @@
|
|||
|
||||
from urllib.parse import quote as urlescape
|
||||
|
||||
from flask import render_template
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
import flask_menu as menu
|
||||
from celery import uuid
|
||||
from flask import render_template, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import or_, func
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.rediscache import has_key, set_key
|
||||
from app.tasks.importtasks import importRepoScreenshot
|
||||
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease
|
||||
from app.utils import *
|
||||
from . import bp, get_package_tabs
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.models.packages import PackageProvides
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from ...logic.LogicError import LogicError
|
||||
from ...logic.packages import do_edit_package
|
||||
|
||||
|
||||
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
|
||||
@menu.register_menu(bp, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
|
||||
@menu.register_menu(bp, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
|
||||
@menu.register_menu(bp, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1', 'lucky': '1' })
|
||||
@bp.route("/packages/")
|
||||
def list_all():
|
||||
qb = QueryBuilder(request.args)
|
||||
|
@ -67,7 +70,7 @@ def list_all():
|
|||
if qb.lucky:
|
||||
package = query.first()
|
||||
if package:
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
topic = qb.buildTopicQuery().first()
|
||||
if qb.search and topic:
|
||||
|
@ -100,7 +103,7 @@ def list_all():
|
|||
selected_tags = set(qb.tags)
|
||||
|
||||
return render_template("packages/list.html",
|
||||
query_hint=title, packages=query.items, pagination=query,
|
||||
title=title, packages=query.items, pagination=query,
|
||||
query=search, tags=tags, selected_tags=selected_tags, type=type_name,
|
||||
authors=authors, packages_count=query.total, topics=topics)
|
||||
|
||||
|
@ -115,36 +118,26 @@ def getReleases(package):
|
|||
@bp.route("/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def view(package):
|
||||
show_similar = not package.approved and (
|
||||
current_user in package.maintainers or
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW))
|
||||
alternatives = None
|
||||
if package.type == PackageType.MOD:
|
||||
alternatives = Package.query \
|
||||
.filter_by(name=package.name, type=PackageType.MOD) \
|
||||
.filter(Package.id != package.id, Package.state!=PackageState.DELETED) \
|
||||
.order_by(db.desc(Package.score)) \
|
||||
.all()
|
||||
|
||||
conflicting_modnames = None
|
||||
if show_similar and package.type != PackageType.TXP:
|
||||
conflicting_modnames = db.session.query(MetaPackage.name) \
|
||||
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
|
||||
.filter(MetaPackage.packages.any(Package.id != package.id)) \
|
||||
.all()
|
||||
|
||||
conflicting_modnames += db.session.query(ForumTopic.name) \
|
||||
.filter(ForumTopic.name.in_([ mp.name for mp in package.provides ])) \
|
||||
show_similar_topics = current_user == package.author or \
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW)
|
||||
|
||||
similar_topics = None if not show_similar_topics else \
|
||||
ForumTopic.query \
|
||||
.filter_by(name=package.name) \
|
||||
.filter(ForumTopic.topic_id != package.forums) \
|
||||
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
conflicting_modnames = set([x[0] for x in conflicting_modnames])
|
||||
|
||||
packages_uses = None
|
||||
if package.type == PackageType.MOD:
|
||||
packages_uses = Package.query.filter(
|
||||
Package.type == PackageType.MOD,
|
||||
Package.id != package.id,
|
||||
Package.state == PackageState.APPROVED,
|
||||
Package.dependencies.any(
|
||||
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
|
||||
.order_by(db.desc(Package.score)).limit(6).all()
|
||||
|
||||
releases = getReleases(package)
|
||||
|
||||
review_thread = package.review_thread
|
||||
|
@ -156,16 +149,16 @@ def view(package):
|
|||
if package.state != PackageState.APPROVED and package.forums is not None:
|
||||
errors = []
|
||||
if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1:
|
||||
errors.append("<b>" + gettext("Error: Another package already uses this forum topic!") + "</b>")
|
||||
errors.append("<b>Error: Another package already uses this forum topic!</b>")
|
||||
topic_error_lvl = "danger"
|
||||
|
||||
topic = ForumTopic.query.get(package.forums)
|
||||
if topic is not None:
|
||||
if topic.author != package.author:
|
||||
errors.append("<b>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>")
|
||||
errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
|
||||
topic_error_lvl = "danger"
|
||||
elif package.type != PackageType.TXP:
|
||||
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created."))
|
||||
errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
|
||||
|
||||
topic_error = "<br />".join(errors)
|
||||
|
||||
|
@ -173,14 +166,14 @@ def view(package):
|
|||
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
|
||||
if not current_user.is_authenticated:
|
||||
threads = threads.filter_by(private=False)
|
||||
elif not current_user.rank.atLeast(UserRank.APPROVER) and not current_user == package.author:
|
||||
elif not current_user.rank.atLeast(UserRank.EDITOR) and not current_user == package.author:
|
||||
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
|
||||
|
||||
has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
||||
|
||||
return render_template("packages/view.html",
|
||||
package=package, releases=releases, packages_uses=packages_uses,
|
||||
conflicting_modnames=conflicting_modnames,
|
||||
package=package, releases=releases,
|
||||
alternatives=alternatives, similar_topics=similar_topics,
|
||||
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
|
||||
threads=threads.all(), has_review=has_review)
|
||||
|
||||
|
@ -189,7 +182,7 @@ def view(package):
|
|||
@is_package_page
|
||||
def shield(package, type):
|
||||
if type == "title":
|
||||
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
|
||||
url = "https://img.shields.io/badge/ContentDB-{}-{}" \
|
||||
.format(urlescape(package.title), urlescape("#375a7f"))
|
||||
elif type == "downloads":
|
||||
#api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
|
||||
|
@ -212,8 +205,8 @@ def download(package):
|
|||
not "text/html" in request.accept_mimetypes:
|
||||
return "", 204
|
||||
else:
|
||||
flash(gettext("No download available."), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
flash("No download available.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
else:
|
||||
return redirect(release.getDownloadURL())
|
||||
|
||||
|
@ -224,29 +217,25 @@ def makeLabel(obj):
|
|||
else:
|
||||
return obj.title
|
||||
|
||||
|
||||
class PackageForm(FlaskForm):
|
||||
type = SelectField(lazy_gettext("Type"), [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
|
||||
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
|
||||
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
||||
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)])
|
||||
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
|
||||
title = StringField("Title (Human-readable)", [InputRequired(), Length(1, 100)])
|
||||
name = StringField("Name (Technical)", [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
|
||||
|
||||
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
|
||||
tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
|
||||
content_warnings = QuerySelectMultipleField('Content Warnings', query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
|
||||
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", [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)
|
||||
|
||||
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
|
||||
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
|
||||
license = QuerySelectField(lazy_gettext("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(lazy_gettext("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)
|
||||
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
|
||||
|
||||
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)])
|
||||
repo = StringField("VCS Repository URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||
website = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||
forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
|
||||
|
||||
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
|
||||
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/packages/new/", methods=["GET", "POST"])
|
||||
|
@ -262,11 +251,11 @@ def create_edit(author=None, name=None):
|
|||
else:
|
||||
author = User.query.filter_by(username=author).first()
|
||||
if author is None:
|
||||
flash(gettext("Unable to find that user"), "danger")
|
||||
flash("Unable to find that user", "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
flash("Permission denied", "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
else:
|
||||
|
@ -274,7 +263,7 @@ def create_edit(author=None, name=None):
|
|||
if package is None:
|
||||
abort(404)
|
||||
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
author = package.author
|
||||
|
||||
|
@ -283,15 +272,15 @@ def create_edit(author=None, name=None):
|
|||
# Initial form class from post data and default data
|
||||
if request.method == "GET":
|
||||
if package is None:
|
||||
form.name.data = request.args.get("bname")
|
||||
form.title.data = request.args.get("title")
|
||||
form.repo.data = request.args.get("repo")
|
||||
form.name.data = request.args.get("bname")
|
||||
form.title.data = request.args.get("title")
|
||||
form.repo.data = request.args.get("repo")
|
||||
form.forums.data = request.args.get("forums")
|
||||
form.license.data = None
|
||||
form.media_license.data = None
|
||||
else:
|
||||
form.tags.data = package.tags
|
||||
form.content_warnings.data = package.content_warnings
|
||||
form.tags.data = list(package.tags)
|
||||
form.content_warnings.data = list(package.content_warnings)
|
||||
|
||||
if request.method == "POST" and form.type.data == PackageType.TXP:
|
||||
form.license.data = form.media_license.data
|
||||
|
@ -304,7 +293,7 @@ def create_edit(author=None, name=None):
|
|||
if package.state == PackageState.READY_FOR_REVIEW:
|
||||
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
|
||||
else:
|
||||
flash(gettext("Package already exists!"), "danger")
|
||||
flash("Package already exists!", "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
package = Package()
|
||||
|
@ -313,12 +302,11 @@ def create_edit(author=None, name=None):
|
|||
wasNew = True
|
||||
|
||||
try:
|
||||
do_edit_package(current_user, package, wasNew, True, {
|
||||
do_edit_package(current_user, package, wasNew, {
|
||||
"type": form.type.data,
|
||||
"title": form.title.data,
|
||||
"name": form.name.data,
|
||||
"short_desc": form.short_desc.data,
|
||||
"dev_state": form.dev_state.data,
|
||||
"tags": form.tags.raw_data,
|
||||
"content_warnings": form.content_warnings.raw_data,
|
||||
"license": form.license.data,
|
||||
|
@ -328,17 +316,16 @@ def create_edit(author=None, name=None):
|
|||
"website": form.website.data,
|
||||
"issueTracker": form.issueTracker.data,
|
||||
"forums": form.forums.data,
|
||||
"video_url": form.video_url.data,
|
||||
})
|
||||
|
||||
if wasNew and package.repo is not None:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
next_url = package.getURL("packages.view")
|
||||
next_url = package.getDetailsURL()
|
||||
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
|
||||
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
|
||||
elif wasNew:
|
||||
next_url = package.getURL("packages.setup_releases")
|
||||
next_url = package.getSetupReleasesURL()
|
||||
|
||||
return redirect(next_url)
|
||||
except LogicError as e:
|
||||
|
@ -365,16 +352,14 @@ def move_to_state(package):
|
|||
abort(400)
|
||||
|
||||
if not package.canMoveToState(current_user, state):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
flash("You don't have permission to do that", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
package.state = state
|
||||
msg = "Marked {} as {}".format(package.title, state.value)
|
||||
|
||||
if state == PackageState.APPROVED:
|
||||
if not package.approved_at:
|
||||
post_discord_webhook.delay(package.author.username,
|
||||
"New package {}".format(package.getURL("packages.view", absolute=True)), False)
|
||||
package.approved_at = datetime.datetime.now()
|
||||
|
||||
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
|
||||
|
@ -382,24 +367,21 @@ def move_to_state(package):
|
|||
s.approved = True
|
||||
|
||||
msg = "Approved {}".format(package.title)
|
||||
elif state == PackageState.READY_FOR_REVIEW:
|
||||
post_discord_webhook.delay(package.author.username,
|
||||
"Ready for Review: {}".format(package.getURL("packages.view", absolute=True)), True)
|
||||
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), package)
|
||||
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if package.state == PackageState.CHANGES_NEEDED:
|
||||
flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
|
||||
flash("Please comment what changes are needed in the review thread", "warning")
|
||||
if package.review_thread:
|
||||
return redirect(package.review_thread.getViewURL())
|
||||
else:
|
||||
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
|
||||
|
@ -410,48 +392,46 @@ def remove(package):
|
|||
return render_template("packages/remove.html", package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="remove")
|
||||
|
||||
reason = request.form.get("reason") or "?"
|
||||
|
||||
if "delete" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that."), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
flash("You don't have permission to do that.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
package.state = PackageState.DELETED
|
||||
|
||||
url = url_for("users.profile", username=package.author.username)
|
||||
msg = "Deleted {}, reason={}".format(package.title, reason)
|
||||
msg = "Deleted {}".format(package.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Deleted package"), "success")
|
||||
flash("Deleted package", "success")
|
||||
|
||||
return redirect(url)
|
||||
elif "unapprove" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that."), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
flash("You don't have permission to do that.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
package.state = PackageState.WIP
|
||||
|
||||
msg = "Unapproved {}, reason={}".format(package.title, reason)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getURL("packages.view"), package)
|
||||
msg = "Unapproved {}".format(package.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Unapproved package"), "success")
|
||||
flash("Unapproved package", "success")
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
|
||||
|
||||
class PackageMaintainersForm(FlaskForm):
|
||||
maintainers_str = StringField(lazy_gettext("Maintainers (Comma-separated)"), [Optional()])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
maintainers_str = StringField("Maintainers (Comma-separated)", [Optional()])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
|
||||
|
@ -459,8 +439,8 @@ class PackageMaintainersForm(FlaskForm):
|
|||
@is_package_page
|
||||
def edit_maintainers(package):
|
||||
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
|
||||
flash(gettext("You do not have permission to edit maintainers"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
flash("You do not have permission to edit maintainers", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
form = PackageMaintainersForm(formdata=request.form)
|
||||
if request.method == "GET":
|
||||
|
@ -470,19 +450,15 @@ def edit_maintainers(package):
|
|||
usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")]
|
||||
users = User.query.filter(func.lower(User.username).in_(usernames)).all()
|
||||
|
||||
thread = package.threads.filter_by(author=get_system_user()).first()
|
||||
|
||||
for user in users:
|
||||
if not user in package.maintainers:
|
||||
if thread:
|
||||
thread.watchers.append(user)
|
||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Added you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
"Added you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
|
||||
|
||||
for user in package.maintainers:
|
||||
if user != package.author and not user in users:
|
||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Removed you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
"Removed you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
|
||||
|
||||
package.maintainers.clear()
|
||||
package.maintainers.extend(users)
|
||||
|
@ -490,13 +466,13 @@ def edit_maintainers(package):
|
|||
package.maintainers.append(package.author)
|
||||
|
||||
msg = "Edited {} maintainers".format(package.title)
|
||||
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getURL("packages.view"), package)
|
||||
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getDetailsURL(), package)
|
||||
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
|
||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
|
||||
|
||||
|
@ -509,28 +485,55 @@ def edit_maintainers(package):
|
|||
@is_package_page
|
||||
def remove_self_maintainers(package):
|
||||
if not current_user in package.maintainers:
|
||||
flash(gettext("You are not a maintainer"), "danger")
|
||||
flash("You are not a maintainer", "danger")
|
||||
|
||||
elif current_user == package.author:
|
||||
flash(gettext("Package owners cannot remove themselves as maintainers"), "danger")
|
||||
flash("Package owners cannot remove themselves as maintainers", "danger")
|
||||
|
||||
else:
|
||||
package.maintainers.remove(current_user)
|
||||
|
||||
addNotification(package.author, current_user, NotificationType.MAINTAINER,
|
||||
"Removed themself as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
"Removed themself as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/import-meta/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def update_from_release(package):
|
||||
if not package.checkPerm(current_user, Permission.REIMPORT_META):
|
||||
flash("You don't have permission to reimport meta", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
release = package.releases.first()
|
||||
if not release:
|
||||
flash("Release needed", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
msg = "Updated meta from latest release"
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT,
|
||||
msg, package.getDetailsURL(), package)
|
||||
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
||||
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
task_id = uuid()
|
||||
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
checkZipRelease.apply_async((release.id, zippath), task_id=task_id)
|
||||
|
||||
return redirect(url_for("tasks.check", id=task_id, r=package.getEditURL()))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/audit/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def audit(package):
|
||||
if not (package.checkPerm(current_user, Permission.EDIT_PACKAGE) or
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW)):
|
||||
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
||||
abort(403)
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
|
@ -539,75 +542,4 @@ def audit(package):
|
|||
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
|
||||
|
||||
pagination = query.paginate(page, num, True)
|
||||
return render_template("packages/audit.html", log=pagination.items, pagination=pagination,
|
||||
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
|
||||
|
||||
|
||||
class PackageAliasForm(FlaskForm):
|
||||
author = StringField(lazy_gettext("Author Name"), [InputRequired(), Length(1, 50)])
|
||||
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100),
|
||||
Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/aliases/")
|
||||
@rank_required(UserRank.EDITOR)
|
||||
@is_package_page
|
||||
def alias_list(package: Package):
|
||||
return render_template("packages/alias_list.html", package=package)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
|
||||
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.EDITOR)
|
||||
@is_package_page
|
||||
def alias_create_edit(package: Package, alias_id: int = None):
|
||||
alias = None
|
||||
if alias_id:
|
||||
alias = PackageAlias.query.get(alias_id)
|
||||
if alias is None or alias.package != package:
|
||||
abort(404)
|
||||
|
||||
form = PackageAliasForm(request.form, obj=alias)
|
||||
if form.validate_on_submit():
|
||||
if alias is None:
|
||||
alias = PackageAlias()
|
||||
alias.package = package
|
||||
db.session.add(alias)
|
||||
|
||||
form.populate_obj(alias)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.alias_list"))
|
||||
|
||||
return render_template("packages/alias_create_edit.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/share/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def share(package):
|
||||
return render_template("packages/share.html", package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="share")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/similar/")
|
||||
@is_package_page
|
||||
def similar(package):
|
||||
packages_modnames = {}
|
||||
for metapackage in package.provides:
|
||||
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
|
||||
Package.state != PackageState.DELETED) \
|
||||
.filter(Package.provides.any(PackageProvides.c.metapackage_id == metapackage.id)) \
|
||||
.order_by(db.desc(Package.score)) \
|
||||
.all()
|
||||
|
||||
similar_topics = ForumTopic.query \
|
||||
.filter_by(name=package.name) \
|
||||
.filter(ForumTopic.topic_id != package.forums) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
return render_template("packages/similar.html", package=package,
|
||||
packages_modnames=packages_modnames, similar_topics=similar_topics)
|
||||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
|
||||
|
|
|
@ -16,11 +16,10 @@
|
|||
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_login import login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
||||
|
@ -49,27 +48,26 @@ def get_mt_releases(is_max):
|
|||
|
||||
|
||||
class CreatePackageReleaseForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
|
||||
vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
|
||||
fileUpload = FileField(lazy_gettext("File Upload"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
||||
title = StringField("Title", [InputRequired(), Length(1, 30)])
|
||||
uploadOpt = RadioField ("Method", choices=[("upload", "File Upload")], default="upload")
|
||||
vcsLabel = StringField("Git reference (ie: commit hash, branch, or tag)", default=None)
|
||||
fileUpload = FileField("File Upload")
|
||||
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
|
||||
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
submit = SubmitField("Save")
|
||||
|
||||
class EditPackageReleaseForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
url = StringField(lazy_gettext("URL"), [Optional()])
|
||||
task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None])
|
||||
approved = BooleanField(lazy_gettext("Is Approved"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
||||
title = StringField("Title", [InputRequired(), Length(1, 30)])
|
||||
url = StringField("URL", [Optional()])
|
||||
task_id = StringField("Task ID", filters = [lambda x: x or None])
|
||||
approved = BooleanField("Is Approved")
|
||||
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
|
||||
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
|
||||
|
@ -77,12 +75,12 @@ class EditPackageReleaseForm(FlaskForm):
|
|||
@is_package_page
|
||||
def create_release(package):
|
||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = CreatePackageReleaseForm()
|
||||
if package.repo is not None:
|
||||
form["uploadOpt"].choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))]
|
||||
form["uploadOpt"].choices = [("vcs", "Import from Git"), ("upload", "Upload .zip file")]
|
||||
if request.method == "GET":
|
||||
form["uploadOpt"].data = "vcs"
|
||||
form.vcsLabel.data = request.args.get("ref")
|
||||
|
@ -146,7 +144,7 @@ def edit_release(package, id):
|
|||
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
|
||||
canApprove = release.checkPerm(current_user, Permission.APPROVE_RELEASE)
|
||||
if not (canEdit or canApprove):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = EditPackageReleaseForm(formdata=request.form, obj=release)
|
||||
|
@ -173,21 +171,21 @@ def edit_release(package, id):
|
|||
release.approved = False
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
return redirect(package.getReleaseListURL())
|
||||
|
||||
return render_template("packages/release_edit.html", package=package, release=release, form=form)
|
||||
|
||||
|
||||
|
||||
class BulkReleaseForm(FlaskForm):
|
||||
set_min = BooleanField(lazy_gettext("Set Min"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
||||
set_min = BooleanField("Set Min")
|
||||
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
set_max = BooleanField(lazy_gettext("Set Max"))
|
||||
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
|
||||
set_max = BooleanField("Set Max")
|
||||
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
only_change_none = BooleanField(lazy_gettext("Only change values previously set as none"))
|
||||
submit = SubmitField(lazy_gettext("Update"))
|
||||
only_change_none = BooleanField("Only change values previously set as none")
|
||||
submit = SubmitField("Update")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
|
||||
|
@ -195,7 +193,7 @@ class BulkReleaseForm(FlaskForm):
|
|||
@is_package_page
|
||||
def bulk_change_release(package):
|
||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = BulkReleaseForm()
|
||||
|
@ -213,7 +211,7 @@ def bulk_change_release(package):
|
|||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
return redirect(package.getReleaseListURL())
|
||||
|
||||
return render_template("packages/release_bulk_change.html", package=package, form=form)
|
||||
|
||||
|
@ -227,25 +225,21 @@ def delete_release(package, id):
|
|||
abort(404)
|
||||
|
||||
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
return redirect(release.getReleaseListURL())
|
||||
|
||||
db.session.delete(release)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
|
||||
class PackageUpdateConfigFrom(FlaskForm):
|
||||
trigger = RadioField(lazy_gettext("Trigger"), [InputRequired()],
|
||||
choices=[(PackageUpdateTrigger.COMMIT, lazy_gettext("New Commit")),
|
||||
(PackageUpdateTrigger.TAG, lazy_gettext("New Tag"))],
|
||||
coerce=PackageUpdateTrigger.coerce, default=PackageUpdateTrigger.TAG)
|
||||
ref = StringField(lazy_gettext("Branch name"), [Optional()], default=None)
|
||||
action = RadioField(lazy_gettext("Action"), [InputRequired()],
|
||||
choices=[("notification", lazy_gettext("Send notification and mark as outdated")), ("make_release", lazy_gettext("Create release"))],
|
||||
default="make_release")
|
||||
submit = SubmitField(lazy_gettext("Save Settings"))
|
||||
disable = SubmitField(lazy_gettext("Disable Automation"))
|
||||
trigger = RadioField("Trigger", [InputRequired()], choices=PackageUpdateTrigger.choices(), coerce=PackageUpdateTrigger.coerce,
|
||||
default=PackageUpdateTrigger.TAG)
|
||||
ref = StringField("Branch name", [Optional()], default=None)
|
||||
action = RadioField("Action", [InputRequired()], choices=[("notification", "Send notification and mark as outdated"), ("make_release", "Create release")], default="make_release")
|
||||
submit = SubmitField("Save Settings")
|
||||
disable = SubmitField("Disable Automation")
|
||||
|
||||
|
||||
def set_update_config(package, form):
|
||||
|
@ -284,8 +278,8 @@ def update_config(package):
|
|||
abort(403)
|
||||
|
||||
if not package.repo:
|
||||
flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger")
|
||||
return redirect(package.getURL("packages.create_edit"))
|
||||
flash("Please add a Git repository URL in order to set up automatic releases", "danger")
|
||||
return redirect(package.getEditURL())
|
||||
|
||||
form = PackageUpdateConfigFrom(obj=package.update_config)
|
||||
if request.method == "GET":
|
||||
|
@ -300,7 +294,7 @@ def update_config(package):
|
|||
|
||||
if form.validate_on_submit():
|
||||
if form.disable.data:
|
||||
flash(gettext("Deleted update configuration"), "success")
|
||||
flash("Deleted update configuration", "success")
|
||||
if package.update_config:
|
||||
db.session.delete(package.update_config)
|
||||
db.session.commit()
|
||||
|
@ -308,10 +302,10 @@ def update_config(package):
|
|||
set_update_config(package, form)
|
||||
|
||||
if not form.disable.data and package.releases.count() == 0:
|
||||
flash(gettext("Now, please create an initial release"), "success")
|
||||
return redirect(package.getURL("packages.create_release"))
|
||||
flash("Now, please create an initial release", "success")
|
||||
return redirect(package.getCreateReleaseURL())
|
||||
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
return redirect(package.getReleaseListURL())
|
||||
|
||||
return render_template("packages/update_config.html", package=package, form=form)
|
||||
|
||||
|
@ -324,7 +318,7 @@ def setup_releases(package):
|
|||
abort(403)
|
||||
|
||||
if package.update_config:
|
||||
return redirect(package.getURL("packages.update_config"))
|
||||
return redirect(package.getUpdateConfigURL())
|
||||
|
||||
return render_template("packages/release_wizard.html", package=package)
|
||||
|
||||
|
|
|
@ -13,9 +13,6 @@
|
|||
#
|
||||
# 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 collections import namedtuple
|
||||
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
|
||||
from . import bp
|
||||
|
||||
|
@ -24,10 +21,8 @@ from flask_login import current_user, login_required
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
|
||||
Permission, AuditSeverity
|
||||
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, addAuditLog
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType
|
||||
from app.utils import is_package_page, addNotification, get_int_or_abort
|
||||
|
||||
|
||||
@bp.route("/reviews/")
|
||||
|
@ -40,19 +35,18 @@ def list_reviews():
|
|||
|
||||
|
||||
class ReviewForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
recommends = RadioField(lazy_gettext("Private"), [InputRequired()],
|
||||
choices=[("yes", lazy_gettext("Yes")), ("no", lazy_gettext("No"))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
|
||||
recommends = RadioField("Private", [InputRequired()], choices=[("yes", "Yes"), ("no", "No")])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def review(package):
|
||||
if current_user in package.maintainers:
|
||||
flash(gettext("You can't review your own package!"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
flash("You can't review your own package!", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
|
||||
|
@ -114,31 +108,22 @@ def review(package):
|
|||
addNotification(package.maintainers, current_user, type, notif_msg,
|
||||
url_for("threads.view", id=thread.id), package)
|
||||
|
||||
if was_new:
|
||||
post_discord_webhook.delay(thread.author.username,
|
||||
"Reviewed {}: {}".format(package.title, thread.getViewURL(absolute=True)), False)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
return render_template("packages/review_create_edit.html",
|
||||
form=form, package=package, review=review)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
|
||||
@bp.route("/packages/<author>/<name>/review/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_review(package, reviewer):
|
||||
review = PackageReview.query \
|
||||
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
|
||||
.first()
|
||||
def delete_review(package):
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if not review.checkPerm(current_user, Permission.DELETE_REVIEW):
|
||||
abort(403)
|
||||
|
||||
thread = review.thread
|
||||
|
||||
reply = ThreadReply()
|
||||
|
@ -149,92 +134,10 @@ def delete_review(package, reviewer):
|
|||
|
||||
thread.review = None
|
||||
|
||||
msg = "Converted review by {} to thread".format(review.author.display_name)
|
||||
addAuditLog(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
|
||||
current_user, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||
|
||||
db.session.delete(review)
|
||||
|
||||
package.recalcScore()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
def handle_review_vote(package: Package, review_id: int):
|
||||
if current_user in package.maintainers:
|
||||
flash(gettext("You can't vote on the reviews on your own package!"), "danger")
|
||||
return
|
||||
|
||||
review: PackageReview = PackageReview.query.get(review_id)
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if review.author == current_user:
|
||||
flash(gettext("You can't vote on your own reviews!"), "danger")
|
||||
return
|
||||
|
||||
is_positive = isYes(request.form["is_positive"])
|
||||
|
||||
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
|
||||
if vote is None:
|
||||
vote = PackageReviewVote()
|
||||
vote.review = review
|
||||
vote.user = current_user
|
||||
vote.is_positive = is_positive
|
||||
db.session.add(vote)
|
||||
elif vote.is_positive == is_positive:
|
||||
db.session.delete(vote)
|
||||
else:
|
||||
vote.is_positive = is_positive
|
||||
|
||||
review.update_score()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review/<int:review_id>/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def review_vote(package, review_id):
|
||||
handle_review_vote(package, review_id)
|
||||
|
||||
next_url = request.args.get("r")
|
||||
if next_url and is_safe_url(next_url):
|
||||
return redirect(next_url)
|
||||
else:
|
||||
return redirect(review.thread.getViewURL())
|
||||
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review-votes/")
|
||||
@rank_required(UserRank.ADMIN)
|
||||
@is_package_page
|
||||
def review_votes(package):
|
||||
user_biases = {}
|
||||
for review in package.reviews:
|
||||
review_sign = 1 if review.recommends else -1
|
||||
for vote in review.votes:
|
||||
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
|
||||
vote_sign = 1 if vote.is_positive else -1
|
||||
vote_bias = review_sign * vote_sign
|
||||
if vote_bias == 1:
|
||||
user_biases[vote.user.username][0] += 1
|
||||
else:
|
||||
user_biases[vote.user.username][1] += 1
|
||||
|
||||
BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with")
|
||||
user_biases_info = []
|
||||
for username, bias in user_biases.items():
|
||||
total_votes = bias[0] + bias[1]
|
||||
balance = bias[0] - bias[1]
|
||||
perc_with = round((100 * bias[0]) / total_votes)
|
||||
user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(package.reviews) - total_votes, perc_with))
|
||||
|
||||
user_biases_info.sort(key=lambda x: -abs(x.balance))
|
||||
|
||||
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
|
||||
user_biases=user_biases_info)
|
||||
|
|
|
@ -16,11 +16,10 @@
|
|||
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.utils import *
|
||||
|
@ -30,20 +29,20 @@ from app.logic.screenshots import do_create_screenshot, do_order_screenshots
|
|||
|
||||
|
||||
class CreateScreenshotForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
||||
fileUpload = FileField(lazy_gettext("File Upload"), [InputRequired()])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
|
||||
fileUpload = FileField("File Upload", [InputRequired()])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
class EditScreenshotForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
||||
approved = BooleanField(lazy_gettext("Is Approved"))
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
|
||||
approved = BooleanField("Is Approved")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
class EditPackageScreenshotsForm(FlaskForm):
|
||||
cover_image = QuerySelectField(lazy_gettext("Cover Image"), [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
cover_image = QuerySelectField("Cover Image", [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"])
|
||||
|
@ -51,10 +50,10 @@ class EditPackageScreenshotsForm(FlaskForm):
|
|||
@is_package_page
|
||||
def screenshots(package):
|
||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
if package.screenshots.count() == 0:
|
||||
return redirect(package.getURL("packages.create_screenshot"))
|
||||
return redirect(package.getNewScreenshotURL())
|
||||
|
||||
form = EditPackageScreenshotsForm(obj=package)
|
||||
form.cover_image.query = package.screenshots
|
||||
|
@ -64,7 +63,7 @@ def screenshots(package):
|
|||
if order:
|
||||
try:
|
||||
do_order_screenshots(current_user, package, order.split(","))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
|
@ -81,14 +80,14 @@ def screenshots(package):
|
|||
@is_package_page
|
||||
def create_screenshot(package):
|
||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = CreateScreenshotForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data, False)
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
|
||||
return redirect(package.getEditScreenshotsURL())
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
|
@ -106,7 +105,7 @@ def edit_screenshot(package, id):
|
|||
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
|
||||
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
|
||||
if not (canEdit or canApprove):
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
return redirect(package.getEditScreenshotsURL())
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = EditScreenshotForm(obj=screenshot)
|
||||
|
@ -122,7 +121,7 @@ def edit_screenshot(package, id):
|
|||
screenshot.approved = wasApproved
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
return redirect(package.getEditScreenshotsURL())
|
||||
|
||||
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
|
||||
|
||||
|
@ -136,7 +135,7 @@ def delete_screenshot(package, id):
|
|||
abort(404)
|
||||
|
||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
flash("Permission denied", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
if package.cover_image == screenshot:
|
||||
|
@ -146,4 +145,4 @@ def delete_screenshot(package, id):
|
|||
db.session.delete(screenshot)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
return redirect(package.getEditScreenshotsURL())
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
# ContentDB
|
||||
# Copyright (C) 2022 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 Blueprint, request, render_template, url_for
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from werkzeug.utils import redirect
|
||||
from wtforms import TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.models import User, UserRank
|
||||
from app.tasks.emails import send_user_email
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from app.utils import isNo, abs_url_samesite
|
||||
|
||||
bp = Blueprint("report", __name__)
|
||||
|
||||
|
||||
class ReportForm(FlaskForm):
|
||||
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)])
|
||||
submit = SubmitField(lazy_gettext("Report"))
|
||||
|
||||
|
||||
@bp.route("/report/", methods=["GET", "POST"])
|
||||
def report():
|
||||
is_anon = not current_user.is_authenticated or not isNo(request.args.get("anon"))
|
||||
|
||||
url = request.args.get("url")
|
||||
if url:
|
||||
url = abs_url_samesite(url)
|
||||
|
||||
form = ReportForm(formdata=request.form)
|
||||
if form.validate_on_submit():
|
||||
if current_user.is_authenticated:
|
||||
user_info = f"{current_user.username}"
|
||||
else:
|
||||
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||
|
||||
text = f"{url}\n\n{form.message.data}"
|
||||
|
||||
task = None
|
||||
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
|
||||
task = send_user_email.delay(admin.email, admin.locale or "en",
|
||||
f"User report from {user_info}", text)
|
||||
|
||||
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
|
||||
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
|
||||
|
||||
return render_template("report/index.html", form=form, url=url, is_anon=is_anon)
|
|
@ -14,22 +14,19 @@
|
|||
# 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 *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
|
||||
from app.markdown import get_user_mentions, render_markdown
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
|
||||
bp = Blueprint("threads", __name__)
|
||||
|
||||
from flask_login import current_user, login_required
|
||||
from app import menu
|
||||
from app.models import *
|
||||
from app.utils import addNotification, isYes, addAuditLog, get_system_user
|
||||
from app.utils import addNotification, isYes, addAuditLog
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from app.utils import get_int_or_abort
|
||||
|
||||
|
||||
@menu.register_menu(bp, ".threads", "Threads", order=20)
|
||||
@bp.route("/threads/")
|
||||
def list_all():
|
||||
query = Thread.query
|
||||
|
@ -61,9 +58,9 @@ def subscribe(id):
|
|||
abort(404)
|
||||
|
||||
if current_user in thread.watchers:
|
||||
flash(gettext("Already subscribed!"), "success")
|
||||
flash("Already subscribed!", "success")
|
||||
else:
|
||||
flash(gettext("Subscribed to thread"), "success")
|
||||
flash("Subscribed to thread", "success")
|
||||
thread.watchers.append(current_user)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -78,11 +75,11 @@ def unsubscribe(id):
|
|||
abort(404)
|
||||
|
||||
if current_user in thread.watchers:
|
||||
flash(gettext("Unsubscribed!"), "success")
|
||||
flash("Unsubscribed!", "success")
|
||||
thread.watchers.remove(current_user)
|
||||
db.session.commit()
|
||||
else:
|
||||
flash(gettext("Already not subscribed!"), "success")
|
||||
flash("Already not subscribed!", "success")
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
@ -101,13 +98,13 @@ def set_lock(id):
|
|||
msg = None
|
||||
if thread.locked:
|
||||
msg = "Locked thread '{}'".format(thread.title)
|
||||
flash(gettext("Locked thread"), "success")
|
||||
flash("Locked thread", "success")
|
||||
else:
|
||||
msg = "Unlocked thread '{}'".format(thread.title)
|
||||
flash(gettext("Unlocked thread"), "success")
|
||||
flash("Unlocked thread", "success")
|
||||
|
||||
addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
@ -153,7 +150,7 @@ def delete_reply(id):
|
|||
abort(404)
|
||||
|
||||
if thread.replies[0] == reply:
|
||||
flash(gettext("Cannot delete thread opening post!"), "danger")
|
||||
flash("Cannot delete thread opening post!", "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
|
||||
|
@ -172,8 +169,8 @@ def delete_reply(id):
|
|||
|
||||
|
||||
class CommentForm(FlaskForm):
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
submit = SubmitField(lazy_gettext("Comment"))
|
||||
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
|
||||
submit = SubmitField("Comment")
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
|
||||
|
@ -214,7 +211,7 @@ def edit_reply(id):
|
|||
|
||||
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
|
||||
def view(id):
|
||||
thread: Thread = Thread.query.get(id)
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
|
@ -222,11 +219,11 @@ def view(id):
|
|||
comment = request.form["comment"]
|
||||
|
||||
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
|
||||
flash(gettext("You cannot comment on this thread"), "danger")
|
||||
flash("You cannot comment on this thread", "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if not current_user.canCommentRL():
|
||||
flash(gettext("Please wait before commenting again"), "danger")
|
||||
flash("Please wait before commenting again", "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if 2000 >= len(comment) > 3:
|
||||
|
@ -239,40 +236,23 @@ def view(id):
|
|||
if not current_user in thread.watchers:
|
||||
thread.watchers.append(current_user)
|
||||
|
||||
for mentioned_username in get_user_mentions(render_markdown(comment)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username)
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
||||
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
msg = "New comment on '{}'".format(thread.title)
|
||||
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
if thread.author == get_system_user():
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
|
||||
thread.getViewURL(), thread.package)
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
else:
|
||||
flash(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
|
||||
flash("Comment needs to be between 3 and 2000 characters.")
|
||||
|
||||
return render_template("threads/view.html", thread=thread)
|
||||
|
||||
|
||||
class ThreadForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
private = BooleanField(lazy_gettext("Private"))
|
||||
submit = SubmitField(lazy_gettext("Open Thread"))
|
||||
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
|
||||
private = BooleanField("Private")
|
||||
submit = SubmitField("Open Thread")
|
||||
|
||||
|
||||
@bp.route("/threads/new/", methods=["GET", "POST"])
|
||||
|
@ -284,7 +264,7 @@ def new():
|
|||
if "pid" in request.args:
|
||||
package = Package.query.get(int(request.args.get("pid")))
|
||||
if package is None:
|
||||
flash(gettext("Unable to find that package!"), "danger")
|
||||
flash("Unable to find that package!", "danger")
|
||||
|
||||
# Don't allow making orphan threads on approved packages for now
|
||||
if package is None:
|
||||
|
@ -298,19 +278,19 @@ def new():
|
|||
|
||||
# Check that user can make the thread
|
||||
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
|
||||
flash(gettext("Unable to create thread!"), "danger")
|
||||
flash("Unable to create thread!", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# Only allow creating one thread when not approved
|
||||
elif is_review_thread and package.review_thread is not None:
|
||||
flash(gettext("An approval thread already exists!"), "danger")
|
||||
flash("A review thread already exists!", "danger")
|
||||
return redirect(package.review_thread.getViewURL())
|
||||
|
||||
elif not current_user.canOpenThreadRL():
|
||||
flash(gettext("Please wait before opening another thread"), "danger")
|
||||
flash("Please wait before opening another thread", "danger")
|
||||
|
||||
if package:
|
||||
return redirect(package.getURL("packages.view"))
|
||||
return redirect(package.getDetailsURL())
|
||||
else:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
@ -345,26 +325,16 @@ def new():
|
|||
if is_review_thread:
|
||||
package.review_thread = thread
|
||||
|
||||
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username)
|
||||
if mentioned is None:
|
||||
continue
|
||||
if package.state == PackageState.READY_FOR_REVIEW and current_user not in package.maintainers:
|
||||
package.state = PackageState.CHANGES_NEEDED
|
||||
|
||||
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
|
||||
addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
notif_msg = "New thread '{}'".format(thread.title)
|
||||
if package is not None:
|
||||
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
|
||||
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
|
||||
|
||||
|
||||
if is_review_thread:
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
editors = User.query.filter(User.rank >= UserRank.EDITOR).all()
|
||||
addNotification(editors, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
@ -372,12 +342,3 @@ def new():
|
|||
|
||||
|
||||
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/comments/")
|
||||
def user_comments(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
return render_template("threads/user_comments.html", user=user, replies=user.replies)
|
|
@ -17,7 +17,7 @@
|
|||
from celery import uuid
|
||||
from flask import *
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, and_
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.models import *
|
||||
from app.querybuilder import QueryBuilder
|
||||
|
@ -63,24 +63,18 @@ def view_editor():
|
|||
else:
|
||||
abort(400)
|
||||
|
||||
license_needed = Package.query \
|
||||
.filter(Package.state.in_([PackageState.READY_FOR_REVIEW, PackageState.APPROVED])) \
|
||||
.filter(or_(Package.license.has(License.name.like("Other %")),
|
||||
Package.media_license.has(License.name.like("Other %")))) \
|
||||
.all()
|
||||
|
||||
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
||||
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
|
||||
|
||||
unfulfilled_meta_packages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(Package.state == PackageState.APPROVED, optional=False)) \
|
||||
.filter(MetaPackage.dependencies.any(optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).count()
|
||||
|
||||
return render_template("todo/editor.html", current_tab="editor",
|
||||
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
|
||||
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
|
||||
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
|
||||
total_packages=total_packages, total_to_tag=total_to_tag,
|
||||
unfulfilled_meta_packages=unfulfilled_meta_packages)
|
||||
|
||||
|
||||
|
@ -99,7 +93,7 @@ def topics():
|
|||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = get_int_or_abort(request.args.get("n"), 100)
|
||||
if num > 100 and not current_user.rank.atLeast(UserRank.APPROVER):
|
||||
if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
|
||||
num = 100
|
||||
|
||||
query = query.paginate(page, num, True)
|
||||
|
@ -160,7 +154,7 @@ def view_user(username=None):
|
|||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.atLeast(UserRank.APPROVER):
|
||||
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
unapproved_packages = user.packages \
|
||||
|
@ -168,11 +162,6 @@ def view_user(username=None):
|
|||
Package.state == PackageState.CHANGES_NEEDED)) \
|
||||
.order_by(db.asc(Package.created_at)).all()
|
||||
|
||||
packages_with_small_screenshots = user.maintained_packages \
|
||||
.filter(Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
|
||||
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
|
||||
.all()
|
||||
|
||||
outdated_packages = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
|
@ -185,14 +174,12 @@ def view_user(username=None):
|
|||
.all()
|
||||
|
||||
needs_tags = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED, Package.tags==None) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
.filter(Package.state != PackageState.DELETED) \
|
||||
.filter_by(tags=None).order_by(db.asc(Package.title)).all()
|
||||
|
||||
return render_template("todo/user.html", current_tab="user", user=user,
|
||||
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
|
||||
needs_tags=needs_tags, topics_to_add=topics_to_add,
|
||||
packages_with_small_screenshots=packages_with_small_screenshots,
|
||||
screenshot_min_size=PackageScreenshot.HARD_MIN_SIZE, screenshot_rec_size=PackageScreenshot.SOFT_MIN_SIZE)
|
||||
needs_tags=needs_tags, topics_to_add=topics_to_add)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
|
||||
|
@ -234,8 +221,8 @@ def apply_all_updates(username):
|
|||
|
||||
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
|
||||
rel.getURL("packages.create_edit"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
|
||||
rel.getEditURL(), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getDetailsURL(), package)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("todo.view_user", username=username))
|
||||
|
|
|
@ -15,9 +15,7 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext, get_locale
|
||||
from flask_login import current_user, login_required, logout_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import or_
|
||||
|
@ -26,24 +24,23 @@ from wtforms.validators import *
|
|||
|
||||
from app.models import *
|
||||
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
|
||||
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
|
||||
nonEmptyOrNone, post_login, is_username_valid
|
||||
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, nonEmptyOrNone
|
||||
from passlib.pwd import genphrase
|
||||
|
||||
from . import bp
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField(lazy_gettext("Username or email"), [InputRequired()])
|
||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
||||
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
|
||||
submit = SubmitField(lazy_gettext("Sign in"))
|
||||
username = StringField("Username or email", [InputRequired()])
|
||||
password = PasswordField("Password", [InputRequired(), Length(6, 100)])
|
||||
remember_me = BooleanField("Remember me", default=True)
|
||||
submit = SubmitField("Sign in")
|
||||
|
||||
|
||||
def handle_login(form):
|
||||
def show_safe_err(err):
|
||||
if "@" in username:
|
||||
flash(gettext("Incorrect email or password"), "danger")
|
||||
flash("Incorrect email or password", "danger")
|
||||
else:
|
||||
flash(err, "danger")
|
||||
|
||||
|
@ -51,24 +48,27 @@ def handle_login(form):
|
|||
username = form.username.data.strip()
|
||||
user = User.query.filter(or_(User.username == username, User.email == username)).first()
|
||||
if user is None:
|
||||
return show_safe_err(gettext(u"User %(username)s does not exist", username=username))
|
||||
return show_safe_err("User {} does not exist".format(username))
|
||||
|
||||
if not check_password_hash(user.password, form.password.data):
|
||||
return show_safe_err(gettext(u"Incorrect password. Did you set one?"))
|
||||
return show_safe_err("Incorrect password. Did you set one?")
|
||||
|
||||
if not user.is_active:
|
||||
flash(gettext("You need to confirm the registration email"), "danger")
|
||||
flash("You need to confirm the registration email", "danger")
|
||||
return
|
||||
|
||||
addAuditLog(AuditSeverity.USER, user, "Logged in using password",
|
||||
url_for("users.profile", username=user.username))
|
||||
db.session.commit()
|
||||
|
||||
if not login_user(user, remember=form.remember_me.data):
|
||||
flash(gettext("Login failed"), "danger")
|
||||
return
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
flash("Logged in successfully.", "success")
|
||||
|
||||
return post_login(user, request.args.get("next"))
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
return redirect(next or url_for("homepage.home"))
|
||||
|
||||
|
||||
@bp.route("/user/login/", methods=["GET", "POST"])
|
||||
|
@ -100,23 +100,19 @@ def logout():
|
|||
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonEmptyOrNone])
|
||||
username = StringField(lazy_gettext("Username"), [InputRequired(),
|
||||
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))])
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
||||
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
||||
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
|
||||
submit = SubmitField(lazy_gettext("Register"))
|
||||
display_name = StringField("Display Name", [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
|
||||
username = StringField("Username", [InputRequired(),
|
||||
Regexp("^[a-zA-Z0-9._-]+$", message="Only a-zA-Z0-9._ allowed")])
|
||||
email = StringField("Email", [InputRequired(), Email()])
|
||||
password = PasswordField("Password", [InputRequired(), Length(6, 100)])
|
||||
question = StringField("What is the result of the above calculation?", [InputRequired()])
|
||||
agree = BooleanField("I agree", [DataRequired()])
|
||||
submit = SubmitField("Register")
|
||||
|
||||
|
||||
def handle_register(form):
|
||||
if form.question.data.strip().lower() != "19":
|
||||
flash(gettext("Incorrect captcha answer"), "danger")
|
||||
return
|
||||
|
||||
if not is_username_valid(form.username.data):
|
||||
flash(gettext("Username is invalid"))
|
||||
flash("Incorrect captcha answer", "danger")
|
||||
return
|
||||
|
||||
user_by_name = User.query.filter(or_(
|
||||
|
@ -127,27 +123,22 @@ def handle_register(form):
|
|||
User.github_username == form.username.data)).first()
|
||||
if user_by_name:
|
||||
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
|
||||
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger")
|
||||
flash("An account already exists for that username but hasn't been claimed yet.", "danger")
|
||||
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
|
||||
else:
|
||||
flash(gettext("That username/display name is already in use, please choose another."), "danger")
|
||||
flash("That username/display name is already in use, please choose another.", "danger")
|
||||
return
|
||||
|
||||
alias_by_name = PackageAlias.query.filter(or_(
|
||||
PackageAlias.author==form.username.data,
|
||||
PackageAlias.author==form.display_name.data)).first()
|
||||
if alias_by_name:
|
||||
flash(gettext("That username/display name is already in use, please choose another."), "danger")
|
||||
return
|
||||
|
||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
||||
if user_by_email:
|
||||
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
|
||||
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
|
||||
display_name=user_by_email.display_name))
|
||||
return redirect(url_for("users.email_sent"))
|
||||
send_anon_email.delay(form.email.data, "Email already in use",
|
||||
"We were unable to create the account as the email is already in use by {}. Try a different email address.".format(
|
||||
user_by_email.display_name))
|
||||
flash("Check your email address to verify your account", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
||||
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
||||
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
|
||||
return
|
||||
|
||||
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
|
||||
|
@ -168,9 +159,10 @@ def handle_register(form):
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
flash("Check your email address to verify your account", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
@bp.route("/user/register/", methods=["GET", "POST"])
|
||||
|
@ -186,8 +178,8 @@ def register():
|
|||
|
||||
|
||||
class ForgotPasswordForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
submit = SubmitField(lazy_gettext("Reset Password"))
|
||||
email = StringField("Email", [InputRequired(), Email()])
|
||||
submit = SubmitField("Reset Password")
|
||||
|
||||
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
|
||||
def forgot_password():
|
||||
|
@ -209,37 +201,42 @@ def forgot_password():
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
else:
|
||||
html = render_template("emails/unable_to_find_account.html")
|
||||
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
|
||||
html, html)
|
||||
send_anon_email.delay(email, "Unable to find account", """
|
||||
<p>
|
||||
We were unable to perform the password reset as we could not find an account
|
||||
associated with this email.
|
||||
</p>
|
||||
<p>
|
||||
If you weren't expecting to receive this email, then you can safely ignore it.
|
||||
</p>
|
||||
""")
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
flash("Check your email address to continue the reset", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
return render_template("users/forgot_password.html", form=form)
|
||||
|
||||
|
||||
class SetPasswordForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [Optional(), Email()])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
|
||||
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
email = StringField("Email", [Optional(), Email()])
|
||||
password = PasswordField("New password", [InputRequired(), Length(8, 100)])
|
||||
password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
class ChangePasswordForm(FlaskForm):
|
||||
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
|
||||
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
old_password = PasswordField("Old password", [InputRequired(), Length(8, 100)])
|
||||
password = PasswordField("New password", [InputRequired(), Length(8, 100)])
|
||||
password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
def handle_set_password(form):
|
||||
one = form.password.data
|
||||
two = form.password2.data
|
||||
if one != two:
|
||||
flash(gettext("Passwords do not match"), "danger")
|
||||
flash("Passwords do not much", "danger")
|
||||
return
|
||||
|
||||
addAuditLog(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
|
||||
|
@ -250,31 +247,19 @@ def handle_set_password(form):
|
|||
newEmail = nonEmptyOrNone(form.email.data)
|
||||
if newEmail and newEmail != current_user.email:
|
||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
||||
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
||||
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
|
||||
return
|
||||
|
||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
||||
if user_by_email:
|
||||
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
|
||||
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
|
||||
display_name=user_by_email.display_name))
|
||||
else:
|
||||
token = randomString(32)
|
||||
token = randomString(32)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = current_user
|
||||
ver.token = token
|
||||
ver.email = newEmail
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
|
||||
flash(gettext("Your password has been changed successfully."), "success")
|
||||
return redirect(url_for("users.email_sent"))
|
||||
ver = UserEmailVerification()
|
||||
ver.user = current_user
|
||||
ver.token = token
|
||||
ver.email = newEmail
|
||||
db.session.add(ver)
|
||||
|
||||
db.session.commit()
|
||||
flash(gettext("Your password has been changed successfully."), "success")
|
||||
flash("Your password has been changed successfully.", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
|
@ -289,7 +274,7 @@ def change_password():
|
|||
if ret:
|
||||
return ret
|
||||
else:
|
||||
flash(gettext("Old password is incorrect"), "danger")
|
||||
flash("Old password is incorrect", "danger")
|
||||
|
||||
return render_template("users/change_set_password.html", form=form,
|
||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
||||
|
@ -317,17 +302,9 @@ def set_password():
|
|||
@bp.route("/user/verify/")
|
||||
def verify_email():
|
||||
token = request.args.get("token")
|
||||
ver: UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
|
||||
ver : UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
|
||||
if ver is None:
|
||||
flash(gettext("Unknown verification token!"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
delta = (datetime.datetime.now() - ver.created_at)
|
||||
delta: datetime.timedelta
|
||||
if delta.total_seconds() > 12*60*60:
|
||||
flash(gettext("Token has expired"), "danger")
|
||||
db.session.delete(ver)
|
||||
db.session.commit()
|
||||
flash("Unknown verification token!", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
user = ver.user
|
||||
|
@ -339,16 +316,15 @@ def verify_email():
|
|||
|
||||
if ver.email and user.email != ver.email:
|
||||
if User.query.filter_by(email=ver.email).count() > 0:
|
||||
flash(gettext("Another user is already using that email"), "danger")
|
||||
flash("Another user is already using that email", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
flash(gettext("Confirmed email change"), "success")
|
||||
flash("Confirmed email change", "success")
|
||||
|
||||
if user.email:
|
||||
send_user_email.delay(user.email,
|
||||
user.locale or "en",
|
||||
gettext("Email address changed"),
|
||||
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
|
||||
"Email address changed",
|
||||
"Your email address has changed. If you didn't request this, please contact an administrator.")
|
||||
|
||||
user.is_active = True
|
||||
user.email = ver.email
|
||||
|
@ -366,15 +342,15 @@ def verify_email():
|
|||
if current_user.is_authenticated:
|
||||
return redirect(url_for("users.profile", username=current_user.username))
|
||||
elif was_activating:
|
||||
flash(gettext("You may now log in"), "success")
|
||||
flash("You may now log in", "success")
|
||||
return redirect(url_for("users.login"))
|
||||
else:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
class UnsubscribeForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
submit = SubmitField(lazy_gettext("Send"))
|
||||
email = StringField("Email", [InputRequired(), Email()])
|
||||
submit = SubmitField("Send")
|
||||
|
||||
|
||||
def unsubscribe_verify():
|
||||
|
@ -388,9 +364,10 @@ def unsubscribe_verify():
|
|||
|
||||
sub.token = randomString(32)
|
||||
db.session.commit()
|
||||
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
|
||||
send_unsubscribe_verify.delay(form.email.data)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
flash("Check your email address to continue the unsubscribe", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
return render_template("users/unsubscribe.html", form=form)
|
||||
|
||||
|
@ -405,7 +382,7 @@ def unsubscribe_manage(sub: EmailSubscription):
|
|||
sub.blacklisted = True
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("That email is now blacklisted. Please contact an admin if you wish to undo this."), "success")
|
||||
flash("That email is now blacklisted. Please contact an admin if you wish to undo this.", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
return render_template("users/unsubscribe.html", user=user)
|
||||
|
@ -420,8 +397,3 @@ def unsubscribe():
|
|||
return unsubscribe_manage(sub)
|
||||
|
||||
return unsubscribe_verify()
|
||||
|
||||
|
||||
@bp.route("/email_sent/")
|
||||
def email_sent():
|
||||
return render_template("users/email_sent.html")
|
||||
|
|
|
@ -13,14 +13,19 @@
|
|||
#
|
||||
# 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_babel import gettext
|
||||
|
||||
from . import bp
|
||||
from flask import redirect, render_template, session, request, flash, url_for
|
||||
from app.models import db, User, UserRank
|
||||
from app.utils import randomString, login_user_set_active, is_username_valid
|
||||
from app.utils import randomString, login_user_set_active
|
||||
from app.tasks.forumtasks import checkForumAccount
|
||||
from app.utils.phpbbparser import getProfile
|
||||
import re
|
||||
|
||||
|
||||
def check_username(username):
|
||||
return username is not None and len(username) >= 2 and re.match("^[A-Za-z0-9._-]*$", username)
|
||||
|
||||
|
||||
|
||||
@bp.route("/user/claim/", methods=["GET", "POST"])
|
||||
|
@ -36,17 +41,17 @@ def claim_forums():
|
|||
else:
|
||||
method = request.args.get("method")
|
||||
|
||||
if not is_username_valid(username):
|
||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
||||
if not check_username(username):
|
||||
flash("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin", "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash(gettext("User has already been claimed"), "danger")
|
||||
flash("User has already been claimed", "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
elif method == "github":
|
||||
if user is None or user.github_username is None:
|
||||
flash(gettext("Unable to get GitHub username for user"), "danger")
|
||||
flash("Unable to get GitHub username for user", "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
else:
|
||||
return redirect(url_for("github.start"))
|
||||
|
@ -61,15 +66,15 @@ def claim_forums():
|
|||
ctype = request.form.get("claim_type")
|
||||
username = request.form.get("username")
|
||||
|
||||
if not is_username_valid(username):
|
||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
||||
if not check_username(username):
|
||||
flash("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin", "danger")
|
||||
elif ctype == "github":
|
||||
task = checkForumAccount.delay(username)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
|
||||
elif ctype == "forum":
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash(gettext("That user has already been claimed!"), "danger")
|
||||
flash("That user has already been claimed!", "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
# Get signature
|
||||
|
@ -83,11 +88,11 @@ def claim_forums():
|
|||
else:
|
||||
message = str(e)
|
||||
|
||||
flash(gettext(u"Error whilst attempting to access forums: %(message)s", message=message), "danger")
|
||||
flash("Error whilst attempting to access forums: " + message, "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
|
||||
if profile is None:
|
||||
flash(gettext("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_forums", username=username))
|
||||
|
||||
# Look for key
|
||||
|
@ -100,17 +105,16 @@ def claim_forums():
|
|||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
ret = login_user_set_active(user, remember=True)
|
||||
if ret is None:
|
||||
flash(gettext("Unable to login as user"), "danger")
|
||||
if login_user_set_active(user, remember=True):
|
||||
return redirect(url_for("users.set_password"))
|
||||
else:
|
||||
flash("Unable to login as user", "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
|
||||
return ret
|
||||
|
||||
else:
|
||||
flash(gettext("Could not find the key in your signature!"), "danger")
|
||||
flash("Could not find the key in your signature!", "danger")
|
||||
return redirect(url_for("users.claim_forums", username=username))
|
||||
else:
|
||||
flash(gettext("Unknown claim type"), "danger")
|
||||
flash("Unknown claim type", "danger")
|
||||
|
||||
return render_template("users/claim_forums.html", username=username, key="cdb_" + token)
|
||||
|
|
|
@ -14,11 +14,8 @@
|
|||
# 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/>.
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import func
|
||||
|
||||
|
@ -46,190 +43,19 @@ def by_forums_username(username):
|
|||
return render_template("users/forums_no_such_user.html", username=username)
|
||||
|
||||
|
||||
class Medal:
|
||||
description: str
|
||||
color: Optional[str]
|
||||
icon: str
|
||||
title: Optional[str]
|
||||
progress: Optional[Tuple[int, int]]
|
||||
|
||||
def __init__(self, description: str, **kwargs):
|
||||
self.description = description
|
||||
self.color = kwargs.get("color", "white")
|
||||
self.icon = kwargs.get("icon", None)
|
||||
self.title = kwargs.get("title", None)
|
||||
self.progress = kwargs.get("progress", None)
|
||||
|
||||
@classmethod
|
||||
def make_unlocked(cls, color: str, icon: str, title: str, description: str):
|
||||
return Medal(description=description, color=color, icon=icon, title=title)
|
||||
|
||||
@classmethod
|
||||
def make_locked(cls, description: str, progress: Tuple[int, int]):
|
||||
return Medal(description=description, progress=progress)
|
||||
|
||||
|
||||
def place_to_color(place: int) -> str:
|
||||
if place == 1:
|
||||
return "gold"
|
||||
elif place == 2:
|
||||
return "#888"
|
||||
elif place == 3:
|
||||
return "#cd7f32"
|
||||
else:
|
||||
return "white"
|
||||
|
||||
|
||||
def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
|
||||
unlocked = []
|
||||
locked = []
|
||||
|
||||
#
|
||||
# REVIEWS
|
||||
#
|
||||
|
||||
users_by_reviews = db.session.query(User.username, func.sum(PackageReview.score).label("karma")) \
|
||||
.select_from(User).join(PackageReview) \
|
||||
.group_by(User.username).order_by(text("karma DESC")).all()
|
||||
try:
|
||||
review_boundary = users_by_reviews[math.floor(len(users_by_reviews) * 0.25)][1] + 1
|
||||
except IndexError:
|
||||
review_boundary = None
|
||||
usernames_by_reviews = [username for username, _ in users_by_reviews]
|
||||
|
||||
review_idx = None
|
||||
review_percent = None
|
||||
review_karma = 0
|
||||
try:
|
||||
review_idx = usernames_by_reviews.index(user.username)
|
||||
review_percent = round(100 * review_idx / len(users_by_reviews), 1)
|
||||
review_karma = max(users_by_reviews[review_idx][1], 0)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if review_percent is not None and review_percent < 25:
|
||||
if review_idx == 0:
|
||||
title = gettext(u"Top reviewer")
|
||||
description = gettext(
|
||||
u"%(display_name)s has written the most helpful reviews on ContentDB.",
|
||||
display_name=user.display_name)
|
||||
elif review_idx <= 2:
|
||||
if review_idx == 1:
|
||||
title = gettext(u"2nd most helpful reviewer")
|
||||
else:
|
||||
title = gettext(u"3rd most helpful reviewer")
|
||||
description = gettext(
|
||||
u"This puts %(display_name)s in the top %(perc)s%%",
|
||||
display_name=user.display_name, perc=review_percent)
|
||||
else:
|
||||
title = gettext(u"Top %(perc)s%% reviewer", perc=review_percent)
|
||||
description = gettext(u"Only %(place)d users have written more helpful reviews.", place=review_idx)
|
||||
|
||||
unlocked.append(Medal.make_unlocked(
|
||||
place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
|
||||
else:
|
||||
description = gettext(u"Consider writing more helpful reviews to get a medal.")
|
||||
if review_idx:
|
||||
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)
|
||||
locked.append(Medal.make_locked(
|
||||
description, (review_karma, review_boundary)))
|
||||
|
||||
#
|
||||
# TOP PACKAGES
|
||||
#
|
||||
all_package_ranks = db.session.query(
|
||||
Package.type,
|
||||
Package.author_id,
|
||||
func.rank().over(
|
||||
order_by=db.desc(Package.score),
|
||||
partition_by=Package.type) \
|
||||
.label("rank")).order_by(db.asc(text("rank"))) \
|
||||
.filter_by(state=PackageState.APPROVED).subquery()
|
||||
|
||||
user_package_ranks = db.session.query(all_package_ranks) \
|
||||
.filter_by(author_id=user.id) \
|
||||
.filter(text("rank <= 30")) \
|
||||
.all()
|
||||
|
||||
user_package_ranks = next(
|
||||
(x for x in user_package_ranks if x[0] == PackageType.MOD or x[2] <= 10),
|
||||
None)
|
||||
if user_package_ranks:
|
||||
top_rank = user_package_ranks[2]
|
||||
top_type = PackageType.coerce(user_package_ranks[0])
|
||||
if top_rank == 1:
|
||||
title = gettext(u"Top %(type)s", type=top_type.text.lower())
|
||||
else:
|
||||
title = gettext(u"Top %(group)d %(type)s", group=top_rank, type=top_type.text.lower())
|
||||
if top_type == PackageType.MOD:
|
||||
icon = "fa-box"
|
||||
elif top_type == PackageType.GAME:
|
||||
icon = "fa-gamepad"
|
||||
else:
|
||||
icon = "fa-paint-brush"
|
||||
|
||||
description = gettext(u"%(display_name)s has a %(type)s placed at #%(place)d.",
|
||||
display_name=user.display_name, type=top_type.text.lower(), place=top_rank)
|
||||
unlocked.append(
|
||||
Medal.make_unlocked(place_to_color(top_rank), icon, title, description))
|
||||
|
||||
#
|
||||
# DOWNLOADS
|
||||
#
|
||||
total_downloads = db.session.query(func.sum(Package.downloads)) \
|
||||
.select_from(User) \
|
||||
.join(User.packages) \
|
||||
.filter(User.id == user.id,
|
||||
Package.state == PackageState.APPROVED).scalar()
|
||||
if total_downloads is None:
|
||||
pass
|
||||
elif total_downloads < 50000:
|
||||
description = gettext(u"Your packages have %(downloads)d downloads in total.", downloads=total_downloads)
|
||||
description += " " + gettext(u"First medal is at 50k.")
|
||||
locked.append(Medal.make_locked(description, (total_downloads, 50000)))
|
||||
else:
|
||||
if total_downloads >= 300000:
|
||||
place = 1
|
||||
title = gettext(u">300k downloads")
|
||||
elif total_downloads >= 100000:
|
||||
place = 2
|
||||
title = gettext(u">100k downloads")
|
||||
elif total_downloads >= 75000:
|
||||
place = 3
|
||||
title = gettext(u">75k downloads")
|
||||
else:
|
||||
place = 10
|
||||
title = gettext(u">50k downloads")
|
||||
description = gettext(u"Has received %(downloads)d downloads across all packages.",
|
||||
display_name=user.display_name, downloads=total_downloads)
|
||||
unlocked.append(Medal.make_unlocked(place_to_color(place), "fa-users", title, description))
|
||||
|
||||
return unlocked, locked
|
||||
|
||||
|
||||
@bp.route("/users/<username>/")
|
||||
def profile(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
packages = user.packages.filter(Package.state != PackageState.DELETED)
|
||||
if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
|
||||
packages = user.packages.filter_by(state=PackageState.APPROVED)
|
||||
maintained_packages = user.maintained_packages.filter_by(state=PackageState.APPROVED)
|
||||
else:
|
||||
packages = user.packages.filter(Package.state != PackageState.DELETED)
|
||||
maintained_packages = user.maintained_packages.filter(Package.state != PackageState.DELETED)
|
||||
packages = packages.filter_by(state=PackageState.APPROVED)
|
||||
packages = packages.order_by(db.asc(Package.title))
|
||||
|
||||
packages = packages.order_by(db.asc(Package.title)).all()
|
||||
maintained_packages = maintained_packages \
|
||||
.filter(Package.author != user) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
unlocked, locked = get_user_medals(user)
|
||||
# Process GET or invalid POST
|
||||
return render_template("users/profile.html", user=user,
|
||||
packages=packages, maintained_packages=maintained_packages,
|
||||
medals_unlocked=unlocked, medals_locked=locked)
|
||||
return render_template("users/profile.html", user=user, packages=packages)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/check/", methods=["POST"])
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext, get_locale
|
||||
from flask_login import current_user, login_required, logout_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import or_
|
||||
|
@ -13,44 +12,35 @@ from . import bp
|
|||
|
||||
|
||||
def get_setting_tabs(user):
|
||||
ret = [
|
||||
return [
|
||||
{
|
||||
"id": "edit_profile",
|
||||
"title": gettext("Edit Profile"),
|
||||
"title": "Edit Profile",
|
||||
"url": url_for("users.profile_edit", username=user.username)
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"title": gettext("Account and Security"),
|
||||
"title": "Account and Security",
|
||||
"url": url_for("users.account", username=user.username)
|
||||
},
|
||||
{
|
||||
"id": "notifications",
|
||||
"title": gettext("Email and Notifications"),
|
||||
"title": "Email and Notifications",
|
||||
"url": url_for("users.email_notifications", username=user.username)
|
||||
},
|
||||
{
|
||||
"id": "api_tokens",
|
||||
"title": gettext("API Tokens"),
|
||||
"title": "API Tokens",
|
||||
"url": url_for("api.list_tokens", username=user.username)
|
||||
},
|
||||
]
|
||||
|
||||
if current_user.rank.atLeast(UserRank.MODERATOR):
|
||||
ret.append({
|
||||
"id": "modtools",
|
||||
"title": gettext("Moderator Tools"),
|
||||
"url": url_for("users.modtools", username=user.username)
|
||||
})
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class UserProfileForm(FlaskForm):
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
|
||||
website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
display_name = StringField("Display Name", [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
|
||||
website_url = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||
donate_url = StringField("Donation URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
def handle_profile_edit(form, user, username):
|
||||
|
@ -63,15 +53,9 @@ def handle_profile_edit(form, user, username):
|
|||
if User.query.filter(User.id != user.id,
|
||||
or_(User.username == form.display_name.data,
|
||||
User.display_name.ilike(form.display_name.data))).count() > 0:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
flash("A user already has that name", "danger")
|
||||
return None
|
||||
|
||||
alias_by_name = PackageAlias.query.filter(or_(
|
||||
PackageAlias.author == form.display_name.data)).first()
|
||||
if alias_by_name:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
return
|
||||
|
||||
user.display_name = form.display_name.data
|
||||
|
||||
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
|
||||
|
@ -96,7 +80,7 @@ def profile_edit(username):
|
|||
abort(404)
|
||||
|
||||
if not user.can_see_edit_profile(current_user):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
flash("Permission denied", "danger")
|
||||
return redirect(url_for("users.profile", username=username))
|
||||
|
||||
form = UserProfileForm(obj=user)
|
||||
|
@ -111,8 +95,8 @@ def profile_edit(username):
|
|||
|
||||
def make_settings_form():
|
||||
attrs = {
|
||||
"email": StringField(lazy_gettext("Email"), [Optional(), Email()]),
|
||||
"submit": SubmitField(lazy_gettext("Save"))
|
||||
"email": StringField("Email", [Optional(), Email()]),
|
||||
"submit": SubmitField("Save")
|
||||
}
|
||||
|
||||
for notificationType in NotificationType:
|
||||
|
@ -139,7 +123,7 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
|
|||
newEmail = form.email.data
|
||||
if newEmail and newEmail != user.email and newEmail.strip() != "":
|
||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
||||
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
||||
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
|
||||
return
|
||||
|
||||
token = randomString(32)
|
||||
|
@ -156,8 +140,10 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(newEmail, token, get_locale().language)
|
||||
return redirect(url_for("users.email_sent"))
|
||||
flash("Check your email to confirm it", "success")
|
||||
|
||||
send_verify_email.delay(newEmail, token)
|
||||
return redirect(url_for("users.email_notifications", username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for("users.email_notifications", username=user.username))
|
||||
|
@ -203,98 +189,36 @@ def email_notifications(username=None):
|
|||
tabs=get_setting_tabs(user), current_tab="notifications")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/settings/account/")
|
||||
class UserAccountForm(FlaskForm):
|
||||
display_name = StringField("Display name", [Optional(), Length(2, 100)])
|
||||
forums_username = StringField("Forums Username", [Optional(), Length(2, 50)])
|
||||
github_username = StringField("GitHub Username", [Optional(), Length(2, 50)])
|
||||
rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
|
||||
default=UserRank.NEW_MEMBER)
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/settings/account/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def account(username):
|
||||
user : User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
|
||||
if not user.can_see_edit_profile(current_user):
|
||||
flash("Permission denied", "danger")
|
||||
return redirect(url_for("users.profile", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/delete/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def delete(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if user.rank.atLeast(UserRank.MODERATOR):
|
||||
flash(gettext("Users with moderator rank or above cannot be deleted"), "danger")
|
||||
return redirect(url_for("users.account", username=username))
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
|
||||
|
||||
if "delete" in request.form and (user.can_delete() or current_user.rank.atLeast(UserRank.ADMIN)):
|
||||
msg = "Deleted user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
|
||||
if current_user.rank.atLeast(UserRank.ADMIN):
|
||||
for pkg in user.packages.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
|
||||
db.session.delete(user)
|
||||
elif "deactivate" in request.form:
|
||||
user.replies.delete()
|
||||
for thread in user.threads.all():
|
||||
db.session.delete(thread)
|
||||
user.email = None
|
||||
user.rank = UserRank.NOT_JOINED
|
||||
|
||||
msg = "Deactivated user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
else:
|
||||
assert False
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if user == current_user:
|
||||
logout_user()
|
||||
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
|
||||
class ModToolsForm(FlaskForm):
|
||||
username = StringField(lazy_gettext("Username"), [Optional(), Length(1, 50)])
|
||||
display_name = StringField(lazy_gettext("Display name"), [Optional(), Length(2, 100)])
|
||||
forums_username = StringField(lazy_gettext("Forums Username"), [Optional(), Length(2, 50)])
|
||||
github_username = StringField(lazy_gettext("GitHub Username"), [Optional(), Length(2, 50)])
|
||||
rank = SelectField(lazy_gettext("Rank"), [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
|
||||
default=UserRank.NEW_MEMBER)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
form = ModToolsForm(obj=user)
|
||||
if form.validate_on_submit():
|
||||
can_edit_account_settings = user.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
|
||||
user.checkPerm(current_user, Permission.CHANGE_RANK)
|
||||
form = UserAccountForm(obj=user) if can_edit_account_settings else None
|
||||
if form and form.validate_on_submit():
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
addAuditLog(severity, current_user, "Edited {}'s account".format(user.display_name),
|
||||
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
# Copy form fields to user_profile fields
|
||||
if user.checkPerm(current_user, Permission.CHANGE_USERNAMES):
|
||||
if user.username != form.username.data:
|
||||
for package in user.packages:
|
||||
alias = PackageAlias(user.username, package.name)
|
||||
package.aliases.append(alias)
|
||||
db.session.add(alias)
|
||||
|
||||
user.username = form.username.data
|
||||
|
||||
user.display_name = form.display_name.data
|
||||
user.forums_username = nonEmptyOrNone(form.forums_username.data)
|
||||
user.github_username = nonEmptyOrNone(form.github_username.data)
|
||||
|
@ -306,63 +230,51 @@ def modtools(username):
|
|||
user.rank = form["rank"].data
|
||||
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg,
|
||||
url_for("users.profile", username=username))
|
||||
url_for("users.profile", username=username))
|
||||
else:
|
||||
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
|
||||
flash("Can't promote a user to a rank higher than yourself!", "danger")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
return redirect(url_for("users.account", username=username))
|
||||
|
||||
return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools")
|
||||
return render_template("users/account.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="account")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/set-email/", methods=["POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools_set_email(username):
|
||||
@bp.route("/users/<username>/delete/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def delete(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
if user.rank.atLeast(UserRank.MODERATOR):
|
||||
flash("Users with moderator rank or above cannot be deleted", "danger")
|
||||
return redirect(url_for("users.account", username=username))
|
||||
|
||||
user.email = request.form["email"]
|
||||
user.is_active = False
|
||||
if request.method == "GET":
|
||||
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
|
||||
|
||||
token = randomString(32)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
if user.can_delete():
|
||||
msg = "Deleted user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
|
||||
db.session.delete(user)
|
||||
else:
|
||||
user.replies.delete()
|
||||
for thread in user.threads.all():
|
||||
db.session.delete(thread)
|
||||
user.email = None
|
||||
user.rank = UserRank.NOT_JOINED
|
||||
|
||||
msg = "Deactivated user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
ver.token = token
|
||||
ver.email = user.email
|
||||
ver.is_password_reset = True
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(user.email, token, user.locale or "en")
|
||||
if user == current_user:
|
||||
logout_user()
|
||||
|
||||
flash(f"Set email and sent a password reset on {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/ban/", methods=["POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools_ban(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
user.rank = UserRank.BANNED
|
||||
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Banned {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
|
|
@ -11,11 +11,6 @@ def populate(session):
|
|||
admin_user.rank = UserRank.ADMIN
|
||||
session.add(admin_user)
|
||||
|
||||
system_user = User("ContentDB", active=False)
|
||||
system_user.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
|
||||
system_user.rank = UserRank.BOT
|
||||
session.add(system_user)
|
||||
|
||||
session.add(MinetestRelease("None", 0))
|
||||
session.add(MinetestRelease("0.4.16/17", 32))
|
||||
session.add(MinetestRelease("5.0", 37))
|
||||
|
@ -27,7 +22,7 @@ def populate(session):
|
|||
for tag in ["Inventory", "Mapgen", "Building",
|
||||
"Mobs and NPCs", "Tools", "Player effects",
|
||||
"Environment", "Transport", "Maintenance", "Plants and farming",
|
||||
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer", "Featured"]:
|
||||
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
|
||||
row = Tag(tag)
|
||||
tags[row.name] = row
|
||||
session.add(row)
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
title: Help
|
||||
toc: False
|
||||
|
||||
|
||||
## General Help
|
||||
|
||||
* [Frequently Asked Questions](faq)
|
||||
* [Content Ratings and Flags](content_flags)
|
||||
* [Non-free Licenses](non_free)
|
||||
* [Why WTFPL is a terrible license](wtfpl)
|
||||
* [Ranks and Permissions](ranks_permissions)
|
||||
* [Contact Us](contact_us)
|
||||
* [Reporting Content](reporting)
|
||||
* [Top Packages Algorithm](top_packages)
|
||||
* [Featured Packages](featured)
|
||||
|
||||
## Help for Package Authors
|
||||
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
title: API
|
||||
|
||||
|
||||
## Resources
|
||||
|
||||
* [How the Minetest client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
|
||||
|
||||
|
||||
## Responses and Error Handling
|
||||
|
||||
If there is an error, the response will be JSON similar to the following with a non-200 status code:
|
||||
|
@ -32,23 +26,6 @@ often other keys with information. For example:
|
|||
```
|
||||
|
||||
|
||||
### Paginated Results
|
||||
|
||||
Some API endpoints returns results in pages. The page number is specified using the `page` query argument, and
|
||||
the number of items is specified using `num`
|
||||
|
||||
The response will be a dictionary with the following keys:
|
||||
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `per_page`: number of items per page, same as `n`
|
||||
* `page_count`: number of pages
|
||||
* `total`: total number of results
|
||||
* `urls`: dictionary containing
|
||||
* `next`: url to next page
|
||||
* `previous`: url to previous page
|
||||
* `items`: array of items
|
||||
|
||||
|
||||
## Authentication
|
||||
|
||||
Not all endpoints require authentication, but it is done using Bearer tokens:
|
||||
|
@ -78,8 +55,6 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
|
|||
* `title`: Human-readable title.
|
||||
* `name`: Technical name (needs permission if already approved).
|
||||
* `short_description`
|
||||
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
|
||||
`LOOKING_FOR_MAINTAINER`.
|
||||
* `tags`: List of [tag](#tags) names.
|
||||
* `content_warnings`: List of [content warning](#content-warnings) names.
|
||||
* `license`: A [license](#licenses) name.
|
||||
|
@ -89,43 +64,19 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
|
|||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
|
||||
* GET `/api/packages/<username>/<name>/dependencies/`
|
||||
* Returns dependencies, with suggested candidates
|
||||
* If query argument `only_hard` is present, only hard deps will be returned.
|
||||
* GET `/api/dependencies/`
|
||||
* Returns `provides` and raw dependencies for all packages.
|
||||
* Supports [Package Queries](#package-queries)
|
||||
* [Paginated result](#paginated-results), max 300 results per page
|
||||
* Each item in `items` will be a dictionary with the following keys:
|
||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
||||
* `author`: Username of the package author.
|
||||
* `name`: Package name.
|
||||
* `provides`: List of technical mod names inside the package.
|
||||
* `depends`: List of hard dependencies.
|
||||
* Each dep will either be a metapackage dependency (`name`), or a
|
||||
package dependency (`author/name`).
|
||||
* `optional_depends`: list of optional dependencies
|
||||
* Same as above.
|
||||
|
||||
You can download a package by building one of the two URLs:
|
||||
|
||||
```
|
||||
https://content.minetest.net/packages/${author}/${name}/download/`
|
||||
https://content.minetest.net/packages/${author}/${name}/releases/${release}/download/`
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Edit package
|
||||
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
|
||||
curl -X PUT http://localhost:5123/api/packages/username/name/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
|
||||
|
||||
# Remove website URL
|
||||
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
|
||||
curl -X PUT http://localhost:5123/api/packages/username/name/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{ "website": null }'
|
||||
```
|
||||
|
@ -226,7 +177,6 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
|
|||
* `url`: absolute URL to screenshot.
|
||||
* `created_at`: ISO time.
|
||||
* `order`: Number used in ordering.
|
||||
* `is_cover_image`: true for cover image.
|
||||
* GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read)
|
||||
* Returns screenshot dictionary like above.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/new/` (Create)
|
||||
|
@ -234,19 +184,12 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
|
|||
* Body is multipart form data.
|
||||
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
|
||||
* `file`: multipart file to upload, like `<input type=file>`.
|
||||
* `is_cover_image`: set cover image to this.
|
||||
* DELETE `/api/packages/<username>/<name>/screenshots/<id>/` (Delete)
|
||||
* Requires authentication.
|
||||
* Deletes screenshot.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/order/`
|
||||
* Requires authentication.
|
||||
* Body is a JSON array containing the screenshot IDs in their order.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/cover-image/`
|
||||
* Requires authentication.
|
||||
* Body is a JSON dictionary with "cover_image" containing the screenshot ID.
|
||||
|
||||
Currently, to get a different size of thumbnail you can replace the number in `/thumbnails/1/` with any number from 1-3.
|
||||
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
|
||||
|
||||
Examples:
|
||||
|
||||
|
@ -255,11 +198,6 @@ Examples:
|
|||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F file=@path/to/screnshot.png
|
||||
|
||||
# Create screenshot and set it as the cover image
|
||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
|
||||
|
||||
# Delete screenshot
|
||||
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
|
||||
|
@ -269,74 +207,26 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/screensho
|
|||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d "[13, 2, 5, 7]"
|
||||
|
||||
# Set cover image
|
||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/cover-image/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d "{ 'cover_image': 123 }"
|
||||
```
|
||||
|
||||
|
||||
## Reviews
|
||||
|
||||
* GET `/api/packages/<username>/<name>/reviews/` (List)
|
||||
* Returns array of review dictionaries with keys:
|
||||
* `user`: dictionary with `display_name` and `username`.
|
||||
* `title`: review title
|
||||
* `comment`: the text
|
||||
* `is_positive`: boolean
|
||||
* `created_at`: iso timestamp
|
||||
* `votes`: dictionary with `helpful` and `unhelpful`,
|
||||
* GET `/api/reviews/` (List)
|
||||
* Returns a paginated response. This is a dictionary with `page`, `url`, and `items`.
|
||||
* [Paginated result](#paginated-results)
|
||||
* `items`: array of review dictionaries, like above
|
||||
* Each review also has a `package` dictionary with `type`, `author` and `name`
|
||||
* Query arguments:
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `n`: number of results per page, max 100
|
||||
* `author`: filter by review author username
|
||||
* `is_positive`: true or false. Default: null
|
||||
* `q`: filter by title (case insensitive, no fulltext search)
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"comment": "This is a really good mod!",
|
||||
"created_at": "2021-11-24T16:18:33.764084",
|
||||
"is_positive": true,
|
||||
"title": "Really good",
|
||||
"user": {
|
||||
"display_name": "rubenwardy",
|
||||
"username": "rubenwardy"
|
||||
},
|
||||
"votes": {
|
||||
"helpful": 0,
|
||||
"unhelpful": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## Topics
|
||||
|
||||
* GET `/api/topics/` ([View](/api/topics/))
|
||||
* See [Topic Queries](#topic-queries)
|
||||
* GET `/api/topics/` ([View](/api/topics/)): Supports [Package Queries](#package-queries), and the following two options:
|
||||
* `show_added`: Show topics which exist as packages, default true.
|
||||
* `show_discarded`: Show topics which have been marked as outdated, default false.
|
||||
|
||||
### Topic Queries
|
||||
|
||||
Example:
|
||||
|
||||
/api/topics/?q=mobs&type=mod&type=game
|
||||
/api/topics/?q=mobs
|
||||
|
||||
Supported query parameters:
|
||||
|
||||
* `q`: Query string.
|
||||
* `type`: Package types (`mod`, `game`, `txp`).
|
||||
* `sort`: Sort by (`name`, `views`, `created_at`).
|
||||
* `sort`: Sort by (`name`, `views`, `date`).
|
||||
* `order`: Sort ascending (`asc`) or descending (`desc`).
|
||||
* `show_added`: Show topics that have an existing package.
|
||||
* `show_discarded`: Show topics marked as discarded.
|
||||
* `limit`: Return at most `limit` topics.
|
||||
|
@ -346,11 +236,9 @@ Supported query parameters:
|
|||
### Tags
|
||||
|
||||
* GET `/api/tags/` ([View](/api/tags/)): List of:
|
||||
* `name`: technical name.
|
||||
* `title`: human-readable title.
|
||||
* `description`: tag description or null.
|
||||
* `is_protected`: boolean, whether the tag is protected (can only be set by Editors in the web interface).
|
||||
* `views`: number of views of this tag.
|
||||
* `name`: technical name
|
||||
* `title`: human-readable title
|
||||
* `description`: tag description or null
|
||||
|
||||
### Content Warnings
|
||||
|
||||
|
@ -394,5 +282,3 @@ Supported query parameters:
|
|||
* `pop_txp`: popular textures
|
||||
* `pop_game`: popular games
|
||||
* `high_reviewed`: highest reviewed
|
||||
* GET `/api/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
|
||||
* `featured`: featured games
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
title: Contact Us
|
||||
|
||||
## Reports
|
||||
|
||||
Please let us know if anything on the ContentDB violates our rules or any applicable
|
||||
laws.
|
||||
|
||||
We take copyright violation and other offenses very seriously.
|
||||
|
||||
<a href="/report/" class="btn btn-primary">Report</a>
|
||||
|
||||
## Other
|
||||
|
||||
<a href="https://rubenwardy.com/contact/" class="btn btn-primary">Contact the admin</a>
|
|
@ -15,27 +15,20 @@ contentdb_flag_blacklist = nonfree, bad_language, drugs
|
|||
|
||||
A flag can be:
|
||||
|
||||
* `nonfree`: can be used to hide packages which do not qualify as
|
||||
'free software', as defined by the Free Software Foundation.
|
||||
* `wip`: packages marked as Work in Progress
|
||||
* `deprecated`: packages marked as Deprecated
|
||||
* `nonfree` - can be used to hide packages which do not qualify as
|
||||
'free software', as defined by the Free Software Foundation.
|
||||
* A content warning, given below.
|
||||
* `*`: hides all content warnings.
|
||||
|
||||
There are also two meta-flags, which are designed so that we can change how different platforms filter the package list
|
||||
without making a release.
|
||||
|
||||
* `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
|
||||
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages
|
||||
* `android_default` - meta-flag that filters out any content with a content warning.
|
||||
* `desktop_default` - meta-flag that doesn't filter anything out for now.
|
||||
|
||||
## Content Warnings
|
||||
|
||||
Packages with mature content will be tagged with a content warning based
|
||||
on the content type.
|
||||
|
||||
* `bad_language`: swearing.
|
||||
* `drugs`: drugs or alcohol.
|
||||
* `bad_language` - swearing.
|
||||
* `drugs` - drugs or alcohol.
|
||||
* `gambling`
|
||||
* `gore`: blood, etc.
|
||||
* `horror`: shocking and scary content.
|
||||
* `violence`: non-cartoon violence.
|
||||
* `gore` - blood, etc.
|
||||
* `horror` - shocking and scary content.
|
||||
* `violence` - non-cartoon violence.
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
title: Frequently Asked Questions
|
||||
|
||||
## Users and Logins
|
||||
|
||||
### How do I create an account?
|
||||
|
||||
How you create an account depends on whether you have a forum account.
|
||||
|
||||
If you have a forum account, then you'll need to prove that you are the owner of the account. This can
|
||||
be done using a GitHub account or a random string in your forum account signature.
|
||||
|
||||
If you don't, then you can just sign up using an email address and password.
|
||||
|
||||
GitHub can only be used to login, not to register.
|
||||
|
||||
<a class="btn btn-primary" href="/user/claim/">Register</a>
|
||||
|
||||
|
||||
### My verification email never arrived
|
||||
|
||||
There are a number of reasons this may have happened:
|
||||
|
||||
* Incorrect email address entered.
|
||||
* Temporary problem with ContentDB.
|
||||
* Email has been unsubscribed.
|
||||
|
||||
If the email doesn't arrive after registering by email, then you'll need to try registering again in 12 hours.
|
||||
Unconfirmed accounts are deleted after 12 hours.
|
||||
|
||||
If the email verification was sent using the Email settings tab, then you can just set a new email.
|
||||
|
||||
If you have previously unsubscribed this email, then ContentDB is completely prevented from sending emails to that
|
||||
address. You'll need to use a different email address, or [contact rubenwardy](https://rubenwardy.com/contact/) to
|
||||
remove your email from the blacklist.
|
||||
|
||||
|
||||
## Packages
|
||||
|
||||
### How can I create releases automatically?
|
||||
|
||||
There are a number of methods:
|
||||
|
||||
* [Git Update Detection](update_config): ContentDB will check your Git repo daily, and create updates or send you notifications.
|
||||
* [Webhooks](release_webhooks): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
|
||||
* the [API](api): This is especially powerful when combined with CI/CD and other API endpoints.
|
||||
|
||||
|
||||
## How do I get help?
|
||||
|
||||
Please [contact rubenwardy](https://rubenwardy.com/contact/).
|
|
@ -1,137 +0,0 @@
|
|||
title: Featured Packages
|
||||
|
||||
<p class="alert alert-warning">
|
||||
<b>Note:</b> This is a draft, and is likely to change
|
||||
</p>
|
||||
|
||||
## What are Featured Packages?
|
||||
|
||||
Featured Packages are shown at the top of the ContentDB homepage. In the future,
|
||||
featured packages may be shown inside the Minetest client.
|
||||
|
||||
The purpose is to promote content that demonstrates a high quality of what is
|
||||
possible in Minetest. The selection should be varied, and should vary over time.
|
||||
The featured content should be content that we are comfortable recommending to
|
||||
a first time player.
|
||||
|
||||
## How are the packages chosen?
|
||||
|
||||
Before a package can be considered, it must fulfil the criteria in the below lists.
|
||||
There are three types of criteria:
|
||||
|
||||
* "MUST": These must absolutely be fulfilled, no exceptions!
|
||||
* "SHOULD": Most of them should be fulfilled, if possible. Some of them can be
|
||||
left out if there's a reason.
|
||||
* "CAN": Can be fulfilled for bonus points, they are entirely optional.
|
||||
|
||||
For a chance to get featured, a package must fulfil all "MUST" criteria and
|
||||
ideally as many "SHOULD" criteria as possible. The more, the better. Thankfully,
|
||||
many criteria are trivial to fulfil. Note that ticking off all the boxes is not
|
||||
enough: Just because a package completes the checklist does not make it good.
|
||||
Other aspects of the package should be rated as well. See this list as a
|
||||
starting point, not as an exhaustive quality control.
|
||||
|
||||
Editors are responsible for maintaining the list of featured packages. Authors
|
||||
can request that their package be considered by opening a thread titled
|
||||
"Feature Package" on their package. To speed things up, they should justify
|
||||
why they meet (or don't meet) the below criteria. Editors must abstain from
|
||||
voting on packages where they have a conflict of interest.
|
||||
|
||||
A package being featured does not mean that it will be featured forever. A
|
||||
package may be unfeatured if it no longer meets the criteria, to make space for
|
||||
other packages to be featured, or for another reason.
|
||||
|
||||
## General Requirements
|
||||
|
||||
### General
|
||||
|
||||
* MUST: Be 100% free and open source (as marked as Free on ContentDB).
|
||||
* MUST: Work out-of-the-box (no weird setup or settings required).
|
||||
* MUST: Be compatible with the latest stable Minetest release.
|
||||
* SHOULD: Use public source control (such as Git).
|
||||
* SHOULD: Have at least 3 reviews, and be largely positive.
|
||||
|
||||
### Stability
|
||||
|
||||
* MUST: Be well maintained (author is present and active).
|
||||
* MUST: Be reasonably stable, with no game-breaking or major bugs.
|
||||
* MUST: The author does not consider the package to be in an
|
||||
experimental/development/alpha state. Beta and "unfinished" packages are fine.
|
||||
* MUST: No error messages from the engine (e.g. missing textures).
|
||||
* SHOULD: No major map breakages (including unknown nodes, corruption, loss of inventories).
|
||||
Map breakages are a sign that the package isn't sufficiently stable.
|
||||
|
||||
Note: Any map breakage will be excused if "disaster relief" (i.e. tools to repair the damage)
|
||||
is available.
|
||||
|
||||
### Meta and packaging
|
||||
|
||||
* MUST: `screenshot.png` is present and up-to-date, with a correct aspect ratio (3:2, at least 300x200).
|
||||
* MUST: Have a high resolution cover image on ContentDB (at least 1280x720 pixels).
|
||||
It may be shown cropped to 16:9 aspect ratio, or shorter.
|
||||
* MUST: mod.conf/game.conf/texture_pack.conf present with:
|
||||
* name (if mod or game)
|
||||
* description
|
||||
* dependencies (if relevant)
|
||||
* `min_minetest_version` and `max_minetest_version` (if relevant)
|
||||
* MUST: Contain a README file and a LICENSE file. These may be `.md` or `.txt`.
|
||||
* README files typically contain helpful links (download, manual, bugtracker, etc), and other
|
||||
information that players or (potential) contributors may need.
|
||||
* SHOULD: All important settings are in settingtypes.txt with description.
|
||||
|
||||
## Game-specific Requirements
|
||||
|
||||
### Meta and packaging
|
||||
|
||||
* MUST: Have a main menu icon and header image.
|
||||
|
||||
### Stability
|
||||
|
||||
* MUST: If any major setting (like `enable_damage`) is unsupported, the game must disable it
|
||||
using `disabled_settings` in the `game.conf`, and deal with it appropriately in the code
|
||||
(e.g. force-disable the setting, as the user may still set the setting in `minetest.conf`)
|
||||
|
||||
### Usability
|
||||
|
||||
* MUST: Unsupported mapgens are disabled in game.conf.
|
||||
* SHOULD: Passes the Beginner Test: A newbie to the game (but not Minetest) wouldn't get completely
|
||||
stuck within the first 5 minutes of playing.
|
||||
* SHOULD: Have good documentation. This may include one or more of:
|
||||
* A craftguide, or other in-game learning system
|
||||
* A manual
|
||||
* A wiki
|
||||
* Something else
|
||||
|
||||
### Gameplay
|
||||
|
||||
* CAN: Passes the Six Hour Test (only applies to sandbox games): The game doesn't run out of new
|
||||
content before the first 6 hours of playing.
|
||||
* CAN: Players don't feel that something in the game is "lacking".
|
||||
|
||||
### Audiovisuals
|
||||
|
||||
* MUST: Audiovisual design should be of good quality.
|
||||
* MUST: No obvious GUI/HUD breakages.
|
||||
* MUST: Sounds have no obvious artifacts like clicks or unintentional noise.
|
||||
* SHOULD: Graphical design is mostly consistent.
|
||||
* SHOULD: Sounds are used.
|
||||
* SHOULD: Sounds are normalized (more or less).
|
||||
|
||||
### Quality Assurance
|
||||
|
||||
* MUST: No flooding the console/log file with warnings.
|
||||
* MUST: No duplicate crafting recipes.
|
||||
* MUST: Highly experimental game features are disabled by default.
|
||||
* MUST: Experimental game features are clearly marked as such.
|
||||
* SHOULD: No unknown nodes/items/objects appear.
|
||||
* SHOULD: No dependency on legacy API calls.
|
||||
* SHOULD: No console warnings.
|
||||
|
||||
### Writing
|
||||
|
||||
* MUST: All items that can be obtained in normal gameplay have `description` set (whether in the definition or meta).
|
||||
* MUST: Game is not littered with typos or bad grammar (a few typos are OK but should be fixed, when found).
|
||||
* SHOULD: All items have unique names (items which disguise themselves as another item are exempt).
|
||||
* SHOULD: The writing style of all item names is grammatical and consistent.
|
||||
* SHOULD: Descriptions of things convey useful and meaningful information (if applicable).
|
||||
* CAN: Text is written in clear and (if possible) simple language.
|
|
@ -50,8 +50,6 @@ It should be a JSON dictionary with one or more of the following optional keys:
|
|||
* `title`: Human-readable title.
|
||||
* `name`: Technical name (needs permission if already approved).
|
||||
* `short_description`
|
||||
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
|
||||
`LOOKING_FOR_MAINTAINER`.
|
||||
* `tags`: List of tag names, see [/api/tags/](/api/tags/).
|
||||
* `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/).
|
||||
* `license`: A license name, see [/api/licenses/](/api/licenses/).
|
||||
|
@ -61,7 +59,6 @@ It should be a JSON dictionary with one or more of the following optional keys:
|
|||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
|
||||
Use `null` to unset fields where relevant.
|
||||
|
||||
|
|
|
@ -5,8 +5,7 @@ title: Ranks and Permissions
|
|||
* **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
|
||||
* **Members** - Trusted to change the meta data of their own packages', but cannot approve their own packages.
|
||||
* **Trusted Members** - Same as above, but can approve their own releases.
|
||||
* **Approvers** - Responsible for approving new packages, screenshots, and releases.
|
||||
* **Editors** - Same as above, and can edit any package or release.
|
||||
* **Editors** - Trusted to edit any package or release, and also responsible for approving new packages.
|
||||
* **Moderators** - Same as above, but can manage users.
|
||||
* **Admins** - Full access.
|
||||
|
||||
|
@ -19,7 +18,6 @@ title: Ranks and Permissions
|
|||
<th colspan=2 class="NEW_MEMBER">New Member</th>
|
||||
<th colspan=2 class="MEMBER">Member</th>
|
||||
<th colspan=2 class="TRUSTED_MEMBER">Trusted</th>
|
||||
<th colspan=2 class="APPROVER">Approver</th>
|
||||
<th colspan=2 class="EDITOR">Editor</th>
|
||||
<th colspan=2 class="MODERATOR">Moderator</th>
|
||||
<th colspan=2 class="ADMIN">Admin</th>
|
||||
|
@ -38,8 +36,6 @@ title: Ranks and Permissions
|
|||
<th>N</th>
|
||||
<th>Y</th>
|
||||
<th>N</th>
|
||||
<th>Y</th>
|
||||
<th>N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -51,8 +47,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -68,8 +62,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td></td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -85,8 +77,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -102,8 +92,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -119,10 +107,8 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td></td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- admin -->
|
||||
|
@ -136,8 +122,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -153,8 +137,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -170,8 +152,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -187,8 +167,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -204,8 +182,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td></td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td></td> <!-- approver -->
|
||||
<td></td>
|
||||
<td></td> <!-- editor -->
|
||||
<td></td>
|
||||
<td></td> <!-- moderator -->
|
||||
|
@ -221,8 +197,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td>✓</td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -238,8 +212,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -255,8 +227,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -272,8 +242,6 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td>✓</td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- approver -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- editor -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- moderator -->
|
||||
|
@ -289,12 +257,10 @@ title: Ranks and Permissions
|
|||
<td></td>
|
||||
<td></td> <!-- trusted member -->
|
||||
<td></td>
|
||||
<td></td> <!-- approver -->
|
||||
<td></td>
|
||||
<td></td> <!-- editor -->
|
||||
<td></td>
|
||||
<th>✓<sup>2</sup></th> <!-- moderator -->
|
||||
<th>✓<sup>1</sup><sup>2</sup></th>
|
||||
<th>✓<sup>3</sup></th> <!-- moderator -->
|
||||
<th>✓<sup>2</sup><sup>3</sup></th>
|
||||
<td>✓</td> <!-- admin -->
|
||||
<td>✓</td>
|
||||
</tr>
|
||||
|
@ -302,5 +268,5 @@ title: Ranks and Permissions
|
|||
</table>
|
||||
|
||||
|
||||
1. Target user cannot be an admin.
|
||||
2 Cannot set user to a higher rank than themselves.
|
||||
2. Target user cannot be an admin.
|
||||
3. Cannot set user to a higher rank than themselves.
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
title: Reporting Content
|
||||
|
||||
Please let us know if anything on the ContentDB violates our rules or any applicable
|
||||
laws.
|
||||
|
||||
We take copyright violation and other offenses very seriously.
|
||||
|
||||
<a href="https://rubenwardy.com/contact/" class="btn btn-success">Contact</a>
|
|
@ -27,7 +27,7 @@ including ones not covered by this document, and to ban users who abuse this ser
|
|||
### 2.1. Acceptable Content
|
||||
|
||||
Sexually-orientated content is not permitted.
|
||||
If in doubt at what this means, [contact us by raising a report](/report/).
|
||||
If in doubt at what this means, [contact us by raising a report](/help/reporting/).
|
||||
|
||||
Mature content is permitted providing that it is labelled correctly.
|
||||
See [Content Flags](/help/content_flags/).
|
||||
|
@ -46,9 +46,6 @@ but still has value. Note that this doesn't mean that you should add a thing
|
|||
you started working on yesterday, it's worth adding all the basic stuff to
|
||||
make your package useful.
|
||||
|
||||
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
|
||||
as this will help advise players.
|
||||
|
||||
Adding non-player facing mods, such as libraries and server tools, is perfectly fine
|
||||
and encouraged. ContentDB isn't just for player-facing things, and adding
|
||||
libraries allows them to be installed when a mod depends on it.
|
||||
|
@ -137,20 +134,6 @@ ContentDB is for the community. We may remove any promotions if we feel that
|
|||
they're inappropriate.
|
||||
|
||||
|
||||
## 6. Reviews and Package Score
|
||||
## 6. Reporting Violations
|
||||
|
||||
You may invite players to review your package(s). One way to do this is by sharing the link found in the
|
||||
"Share and Badges" page of the package's settings.
|
||||
|
||||
You must not require anyone to review a package. You must not promise or provide incentives for reviewing a package,
|
||||
including but not limited to monetary rewards, in-game items, features, and/or privileges.
|
||||
You may give a cosmetic-only role or badge to those who review your package - this must not be tied to the content or
|
||||
rating of the review.
|
||||
|
||||
You must not attempt to unfairly manipulate your package's ranking, whether by reviews or any other method.
|
||||
Doing so may result in temporary or permanent suspension from ContentDB.
|
||||
|
||||
|
||||
## 7. Reporting Violations
|
||||
|
||||
Please click "Report" on the package page.
|
||||
See the [Reporting Content](/help/reporting/) page.
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
title: Privacy Policy
|
||||
|
||||
Last Updated: 2022-01-23
|
||||
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
|
||||
|
||||
## What Information is Collected
|
||||
|
||||
**All users:**
|
||||
|
@ -12,14 +9,13 @@ Last Updated: 2022-01-23
|
|||
* IP address
|
||||
* Page URL
|
||||
* Response status code
|
||||
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
|
||||
|
||||
**With an account:**
|
||||
|
||||
* Email address
|
||||
* Passwords (hashed and salted using BCrypt)
|
||||
* Profile information, such as website URLs and donation URLs
|
||||
* Comments, threads, and reviews
|
||||
* Comments and threads
|
||||
* Audit log actions (such as edits and logins) and their time stamps
|
||||
|
||||
ContentDB collects usernames of content creators from the forums,
|
||||
|
@ -34,12 +30,10 @@ Please avoid giving other personal information as we do not want it.
|
|||
|
||||
* Logged HTTP requests may be used for debugging ContentDB.
|
||||
* Email addresses are used to:
|
||||
* Provide essential system messages, such as password resets and privacy policy updates.
|
||||
* Provide essential system messages, such as password resets.
|
||||
* Send notifications - the user may configure this to their needs, including opting out.
|
||||
* The admin may use ContentDB to send emails when they need to contact a user.
|
||||
* Passwords are used to authenticate the user.
|
||||
* The audit log is used to record actions that may be harmful.
|
||||
* Preferred language/locale is used to translate emails and the ContentDB interface.
|
||||
* The audit log is used to record actions that may be harmful
|
||||
* Other information is displayed as part of ContentDB's service.
|
||||
|
||||
## Who has access
|
||||
|
@ -49,7 +43,7 @@ Please avoid giving other personal information as we do not want it.
|
|||
* Encrypted backups may be shared with selected Minetest staff members (moderators + core devs).
|
||||
The keys and the backups themselves are given to different people,
|
||||
requiring at least two staff members to read a backup.
|
||||
* Email addresses are visible to moderators and the admin.
|
||||
* Emails are visible to moderators and the admin.
|
||||
They have access to assist users, and they are not permitted to share email addresses.
|
||||
* Hashing protects passwords from being read whilst stored in the database or in backups.
|
||||
* Profile information is public, including URLs and linked accounts.
|
||||
|
@ -58,12 +52,11 @@ Please avoid giving other personal information as we do not want it.
|
|||
* The complete audit log is visible to moderators.
|
||||
Users may see their own audit log actions on their account settings page.
|
||||
Owners, maintainers, and editors may be able to see the actions on a package in the future.
|
||||
* Preferred language can only be viewed by this with access to the database or a backup.
|
||||
* We may be required to share information with law enforcement.
|
||||
|
||||
## Location
|
||||
|
||||
The ContentDB production server is currently located in Germany.
|
||||
The ContentDB production server is currently located in Canada.
|
||||
Backups are stored in the UK.
|
||||
Encrypted backups may be stored in other countries, such as the US or EU.
|
||||
|
||||
|
@ -79,7 +72,7 @@ requested. See below.
|
|||
|
||||
## Removal Requests
|
||||
|
||||
Please [raise a report](https://content.minetest.net/report/?anon=0) if you
|
||||
Please [raise a report](https://content.minetest.net/help/reporting/) if you
|
||||
wish to remove your personal information.
|
||||
|
||||
ContentDB keeps a record of each username and forum topic on the forums,
|
||||
|
|
|
@ -1,188 +0,0 @@
|
|||
# ContentDB
|
||||
# Copyright (C) 2022 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/>.
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
from typing import List, Dict, Optional, Iterator, Iterable
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport, db
|
||||
|
||||
"""
|
||||
get_game_support(package):
|
||||
if package is a game:
|
||||
return [ package ]
|
||||
|
||||
for all hard dependencies:
|
||||
support = support AND get_meta_package_support(dep)
|
||||
|
||||
return support
|
||||
|
||||
get_meta_package_support(meta):
|
||||
for package implementing meta package:
|
||||
support = support OR get_game_support(package)
|
||||
|
||||
return support
|
||||
"""
|
||||
|
||||
|
||||
minetest_game_mods = {
|
||||
"beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
|
||||
"give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
|
||||
"binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
|
||||
"keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
|
||||
}
|
||||
|
||||
|
||||
mtg_mod_blacklist = {
|
||||
"repixture", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
|
||||
"survivethedays"
|
||||
}
|
||||
|
||||
|
||||
class PackageSet:
|
||||
packages: Dict[str, Package]
|
||||
|
||||
def __init__(self, packages: Optional[Iterable[Package]] = None):
|
||||
self.packages = {}
|
||||
if packages:
|
||||
self.update(packages)
|
||||
|
||||
def update(self, packages: Iterable[Package]):
|
||||
for package in packages:
|
||||
key = package.getId()
|
||||
if key not in self.packages:
|
||||
self.packages[key] = package
|
||||
|
||||
def intersection_update(self, other):
|
||||
keys = set(self.packages.keys())
|
||||
keys.difference_update(set(other.packages.keys()))
|
||||
for key in keys:
|
||||
del self.packages[key]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.packages)
|
||||
|
||||
def __iter__(self):
|
||||
return self.packages.values().__iter__()
|
||||
|
||||
|
||||
class GameSupportResolver:
|
||||
checked_packages = set()
|
||||
checked_metapackages = set()
|
||||
resolved_packages: Dict[str, PackageSet] = {}
|
||||
resolved_metapackages: Dict[str, PackageSet] = {}
|
||||
|
||||
def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> PackageSet:
|
||||
print(f"Resolving for {meta.name}", file=sys.stderr)
|
||||
|
||||
key = meta.name
|
||||
if key in self.resolved_metapackages:
|
||||
return self.resolved_metapackages.get(key)
|
||||
|
||||
if key in self.checked_metapackages:
|
||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
||||
return PackageSet()
|
||||
|
||||
self.checked_metapackages.add(key)
|
||||
|
||||
retval = PackageSet()
|
||||
|
||||
for package in meta.packages:
|
||||
if package.state != PackageState.APPROVED:
|
||||
continue
|
||||
|
||||
if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
|
||||
continue
|
||||
|
||||
ret = self.resolve(package, history)
|
||||
if len(ret) == 0:
|
||||
retval = PackageSet()
|
||||
break
|
||||
|
||||
retval.update(ret)
|
||||
|
||||
self.resolved_metapackages[key] = retval
|
||||
return retval
|
||||
|
||||
def resolve(self, package: Package, history: List[str]) -> PackageSet:
|
||||
db.session.merge(package)
|
||||
|
||||
key = package.getId()
|
||||
print(f"Resolving for {key}", file=sys.stderr)
|
||||
|
||||
history = history.copy()
|
||||
history.append(key)
|
||||
|
||||
if package.type == PackageType.GAME:
|
||||
return PackageSet([package])
|
||||
|
||||
if key in self.resolved_packages:
|
||||
return self.resolved_packages.get(key)
|
||||
|
||||
if key in self.checked_packages:
|
||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
||||
return PackageSet()
|
||||
|
||||
self.checked_packages.add(key)
|
||||
|
||||
if package.type != PackageType.MOD:
|
||||
raise LogicError(500, "Got non-mod")
|
||||
|
||||
retval = PackageSet()
|
||||
|
||||
for dep in package.dependencies.filter_by(optional=False).all():
|
||||
ret = self.resolve_for_meta_package(dep.meta_package, history)
|
||||
if len(ret) == 0:
|
||||
continue
|
||||
elif len(retval) == 0:
|
||||
retval.update(ret)
|
||||
else:
|
||||
retval.intersection_update(ret)
|
||||
if len(retval) == 0:
|
||||
raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games")
|
||||
|
||||
self.resolved_packages[key] = retval
|
||||
return retval
|
||||
|
||||
def update_all(self) -> None:
|
||||
for package in Package.query.filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
|
||||
retval = self.resolve(package, [])
|
||||
for game in retval:
|
||||
support = PackageGameSupport(package, game)
|
||||
db.session.add(support)
|
||||
|
||||
def update(self, package: Package) -> None:
|
||||
previous_supported: Dict[str, PackageGameSupport] = {}
|
||||
for support in package.supported_games.all():
|
||||
previous_supported[support.game.getId()] = support
|
||||
|
||||
retval = self.resolve(package, [])
|
||||
for game in retval:
|
||||
assert game
|
||||
|
||||
lookup = previous_supported.pop(game.getId(), None)
|
||||
if lookup is None:
|
||||
support = PackageGameSupport(package, game)
|
||||
db.session.add(support)
|
||||
elif lookup.confidence == 0:
|
||||
lookup.supports = True
|
||||
db.session.merge(lookup)
|
||||
|
||||
for game, support in previous_supported.items():
|
||||
if support.confidence == 0:
|
||||
db.session.remove(support)
|
|
@ -17,13 +17,10 @@
|
|||
|
||||
import re
|
||||
import validators
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
|
||||
License, UserRank, PackageDevState
|
||||
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, License
|
||||
from app.utils import addAuditLog
|
||||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def check(cond: bool, msg: str):
|
||||
|
@ -37,24 +34,23 @@ def get_license(name):
|
|||
|
||||
license = License.query.filter(License.name.ilike(name)).first()
|
||||
if license is None:
|
||||
raise LogicError(400, "Unknown license " + name)
|
||||
raise LogicError(400, "Unknown license: " + name)
|
||||
return license
|
||||
|
||||
|
||||
name_re = re.compile("^[a-z0-9_]+$")
|
||||
|
||||
AnyType = "?"
|
||||
any = "?"
|
||||
ALLOWED_FIELDS = {
|
||||
"type": AnyType,
|
||||
"type": any,
|
||||
"title": str,
|
||||
"name": str,
|
||||
"short_description": str,
|
||||
"short_desc": str,
|
||||
"dev_state": AnyType,
|
||||
"tags": list,
|
||||
"content_warnings": list,
|
||||
"license": AnyType,
|
||||
"media_license": AnyType,
|
||||
"license": any,
|
||||
"media_license": any,
|
||||
"long_description": str,
|
||||
"desc": str,
|
||||
"repo": str,
|
||||
|
@ -62,7 +58,6 @@ ALLOWED_FIELDS = {
|
|||
"issue_tracker": str,
|
||||
"issueTracker": str,
|
||||
"forums": int,
|
||||
"video_url": str,
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
|
@ -85,14 +80,14 @@ def validate(data: dict):
|
|||
if value is not None:
|
||||
typ = ALLOWED_FIELDS.get(key)
|
||||
check(typ is not None, key + " is not a known field")
|
||||
if typ != AnyType:
|
||||
if typ != any:
|
||||
check(isinstance(value, typ), key + " must be a " + typ.__name__)
|
||||
|
||||
if "name" in data:
|
||||
name = data["name"]
|
||||
check(isinstance(name, str), "Name must be a string")
|
||||
check(bool(name_re.match(name)),
|
||||
lazy_gettext("Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)"))
|
||||
"Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)")
|
||||
|
||||
for key in ["repo", "website", "issue_tracker", "issueTracker"]:
|
||||
value = data.get(key)
|
||||
|
@ -103,14 +98,13 @@ def validate(data: dict):
|
|||
check(validators.url(value, public=True), key + " must be a valid URL")
|
||||
|
||||
|
||||
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
|
||||
reason: str = None):
|
||||
def do_edit_package(user: User, package: Package, was_new: bool, data: dict, reason: str = None):
|
||||
if not package.checkPerm(user, Permission.EDIT_PACKAGE):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to edit this package"))
|
||||
raise LogicError(403, "You do not have permission to edit this package")
|
||||
|
||||
if "name" in data and package.name != data["name"] and \
|
||||
not package.checkPerm(user, Permission.CHANGE_NAME):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to change the package name"))
|
||||
raise LogicError(403, "You do not have permission to change the package name")
|
||||
|
||||
for alias, to in ALIASES.items():
|
||||
if alias in data:
|
||||
|
@ -121,22 +115,14 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||
if "type" in data:
|
||||
data["type"] = PackageType.coerce(data["type"])
|
||||
|
||||
if "dev_state" in data:
|
||||
data["dev_state"] = PackageDevState.coerce(data["dev_state"])
|
||||
|
||||
if "license" in data:
|
||||
data["license"] = get_license(data["license"])
|
||||
|
||||
if "media_license" in data:
|
||||
data["media_license"] = get_license(data["media_license"])
|
||||
|
||||
if "video_url" in data and data["video_url"] is not None:
|
||||
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
|
||||
if "dQw4w9WgXcQ" in data["video_url"]:
|
||||
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
|
||||
|
||||
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
|
||||
"repo", "website", "issueTracker", "forums", "video_url"]:
|
||||
for key in ["name", "title", "short_desc", "desc", "type", "license", "media_license",
|
||||
"repo", "website", "issueTracker", "forums"]:
|
||||
if key in data:
|
||||
setattr(package, key, data[key])
|
||||
|
||||
|
@ -148,28 +134,15 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||
package.provides.append(m)
|
||||
|
||||
if "tags" in data:
|
||||
old_tags = list(package.tags)
|
||||
package.tags.clear()
|
||||
for tag_id in data["tags"]:
|
||||
if is_int(tag_id):
|
||||
tag = Tag.query.get(tag_id)
|
||||
package.tags.append(Tag.query.get(tag_id))
|
||||
else:
|
||||
tag = Tag.query.filter_by(name=tag_id).first()
|
||||
if tag is None:
|
||||
raise LogicError(400, "Unknown tag: " + tag_id)
|
||||
|
||||
if not was_web and tag.is_protected:
|
||||
continue
|
||||
|
||||
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
|
||||
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))
|
||||
|
||||
package.tags.append(tag)
|
||||
|
||||
if not was_web:
|
||||
for tag in old_tags:
|
||||
if tag.is_protected:
|
||||
package.tags.append(tag)
|
||||
package.tags.append(tag)
|
||||
|
||||
if "content_warnings" in data:
|
||||
package.content_warnings.clear()
|
||||
|
@ -189,7 +162,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||
msg = "Edited {} ({})".format(package.title, reason)
|
||||
|
||||
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
|
||||
addAuditLog(severity, user, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(severity, user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
import datetime, re
|
||||
|
||||
from celery import uuid
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.uploads import upload_file
|
||||
|
@ -29,12 +28,12 @@ from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
|
|||
|
||||
def check_can_create_release(user: User, package: Package):
|
||||
if not package.checkPerm(user, Permission.MAKE_RELEASE):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to make releases"))
|
||||
raise LogicError(403, "You do not have permission to make releases")
|
||||
|
||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
||||
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
|
||||
if count >= 5:
|
||||
raise LogicError(429, lazy_gettext("You've created too many releases for this package in the last 5 minutes, please wait before trying again"))
|
||||
raise LogicError(429, "You've created too many releases for this package in the last 5 minutes, please wait before trying again")
|
||||
|
||||
|
||||
def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
|
||||
|
@ -54,7 +53,7 @@ def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
|
|||
msg = "Created release {}".format(rel.title)
|
||||
else:
|
||||
msg = "Created release {} ({})".format(rel.title, reason)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
@ -71,7 +70,7 @@ def do_create_zip_release(user: User, package: Package, title: str, file,
|
|||
if commit_hash:
|
||||
commit_hash = commit_hash.lower()
|
||||
if not (len(commit_hash) == 40 and re.match(r"^[0-9a-f]+$", commit_hash)):
|
||||
raise LogicError(400, lazy_gettext("Invalid commit hash; it must be a 40 character long base16 string"))
|
||||
raise LogicError(400, "Invalid commit hash; it must be a 40 character long base16 string")
|
||||
|
||||
uploaded_url, uploaded_path = upload_file(file, "zip", "a zip file")
|
||||
|
||||
|
@ -89,7 +88,7 @@ def do_create_zip_release(user: User, package: Package, title: str, file,
|
|||
msg = "Created release {}".format(rel.title)
|
||||
else:
|
||||
msg = "Created release {} ({})".format(rel.title, reason)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
|
|
@ -1,21 +1,18 @@
|
|||
import datetime, json
|
||||
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.uploads import upload_file
|
||||
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
|
||||
from app.utils import addNotification, addAuditLog
|
||||
from app.utils.image import get_image_size
|
||||
|
||||
|
||||
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
|
||||
def do_create_screenshot(user: User, package: Package, title: str, file, reason: str = None):
|
||||
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
|
||||
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
|
||||
if count >= 20:
|
||||
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again"))
|
||||
raise LogicError(429, "Too many requests, please wait before trying again")
|
||||
|
||||
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG or JPG image file"))
|
||||
uploaded_url, uploaded_path = upload_file(file, "image", "a PNG or JPG image file")
|
||||
|
||||
counter = 1
|
||||
for screenshot in package.screenshots.all():
|
||||
|
@ -28,13 +25,6 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
|
|||
ss.url = uploaded_url
|
||||
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
|
||||
ss.order = counter
|
||||
ss.width, ss.height = get_image_size(uploaded_path)
|
||||
|
||||
if ss.is_too_small():
|
||||
raise LogicError(429,
|
||||
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
|
||||
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
|
||||
|
||||
db.session.add(ss)
|
||||
|
||||
if reason is None:
|
||||
|
@ -42,15 +32,11 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
|
|||
else:
|
||||
msg = "Created screenshot {} ({})".format(ss.title, reason)
|
||||
|
||||
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getDetailsURL(), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if is_cover_image:
|
||||
package.cover_image = ss
|
||||
db.session.commit()
|
||||
|
||||
return ss
|
||||
|
||||
|
||||
|
@ -70,18 +56,3 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
|
|||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def do_set_cover_image(_user: User, package: Package, cover_image):
|
||||
try:
|
||||
cover_image = int(cover_image)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
|
||||
|
||||
for screenshot in package.screenshots.all():
|
||||
if screenshot.id == cover_image:
|
||||
package.cover_image = screenshot
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
raise LogicError(400, "Unable to find screenshot")
|
||||
|
|
|
@ -18,8 +18,6 @@
|
|||
import imghdr
|
||||
import os
|
||||
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import *
|
||||
from app.utils import randomString
|
||||
|
@ -49,10 +47,10 @@ def upload_file(file, fileType, fileTypeDesc):
|
|||
|
||||
ext = get_extension(file.filename)
|
||||
if ext is None or not ext in allowedExtensions:
|
||||
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc))
|
||||
raise LogicError(400, "Please upload " + fileTypeDesc)
|
||||
|
||||
if isImage and not isAllowedImage(file.stream.read()):
|
||||
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
|
||||
raise LogicError(400, "Uploaded image isn't actually an image")
|
||||
|
||||
file.stream.seek(0)
|
||||
|
||||
|
|
|
@ -70,15 +70,10 @@ class FlaskMailHandler(logging.Handler):
|
|||
return subject
|
||||
|
||||
def emit(self, record):
|
||||
subject = self.getSubject(record)
|
||||
text = self.format(record) if self.formatter else None
|
||||
html = "<pre>{}</pre>".format(text)
|
||||
|
||||
if "The recipient has exceeded message rate limit. Try again later" in subject:
|
||||
return
|
||||
|
||||
for email in self.send_to:
|
||||
send_user_email.delay(email, "en", subject, text, html)
|
||||
send_user_email.delay(email, self.getSubject(record), text, html)
|
||||
|
||||
|
||||
def build_handler(app):
|
||||
|
|
179
app/markdown.py
|
@ -1,179 +0,0 @@
|
|||
from functools import partial
|
||||
|
||||
import bleach
|
||||
from bleach import Cleaner
|
||||
from bleach.linkifier import LinkifyFilter
|
||||
from bs4 import BeautifulSoup
|
||||
from markdown import Markdown
|
||||
from flask import Markup, url_for
|
||||
from markdown.extensions import Extension
|
||||
from markdown.inlinepatterns import SimpleTagInlineProcessor
|
||||
from markdown.inlinepatterns import Pattern
|
||||
from xml.etree import ElementTree
|
||||
|
||||
# Based on
|
||||
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
|
||||
#
|
||||
# License: MIT
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
||||
"ul", "ol", "li",
|
||||
"p",
|
||||
"br",
|
||||
"pre",
|
||||
"code",
|
||||
"blockquote",
|
||||
"strong",
|
||||
"em",
|
||||
"a",
|
||||
"img",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"div", "span", "del", "s",
|
||||
]
|
||||
|
||||
ALLOWED_CSS = [
|
||||
"highlight", "codehilite",
|
||||
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
|
||||
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
|
||||
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
|
||||
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
|
||||
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
|
||||
]
|
||||
|
||||
|
||||
def allow_class(_tag, name, value):
|
||||
return name == "class" and value in ALLOWED_CSS
|
||||
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
"h1": ["id"],
|
||||
"h2": ["id"],
|
||||
"h3": ["id"],
|
||||
"h4": ["id"],
|
||||
"a": ["href", "title", "data-username"],
|
||||
"img": ["src", "title", "alt"],
|
||||
"code": allow_class,
|
||||
"div": allow_class,
|
||||
"span": allow_class,
|
||||
}
|
||||
|
||||
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
||||
|
||||
md = None
|
||||
|
||||
|
||||
def render_markdown(source):
|
||||
html = md.convert(source)
|
||||
|
||||
cleaner = Cleaner(
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)])
|
||||
return cleaner.clean(html)
|
||||
|
||||
|
||||
class DelInsExtension(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
del_proc = SimpleTagInlineProcessor(r"(\~\~)(.+?)(\~\~)", "del")
|
||||
md.inlinePatterns.register(del_proc, "del", 200)
|
||||
|
||||
ins_proc = SimpleTagInlineProcessor(r"(\+\+)(.+?)(\+\+)", "ins")
|
||||
md.inlinePatterns.register(ins_proc, "ins", 200)
|
||||
|
||||
|
||||
RE_PARTS = dict(
|
||||
USER=r"[A-Za-z0-9._-]*\b",
|
||||
REPO=r"[A-Za-z0-9_]+\b"
|
||||
)
|
||||
|
||||
|
||||
class MentionPattern(Pattern):
|
||||
ANCESTOR_EXCLUDES = ("a",)
|
||||
|
||||
def __init__(self, config, md):
|
||||
MENTION_RE = r"(@({USER})(?:\/({REPO}))?)".format(**RE_PARTS)
|
||||
super(MentionPattern, self).__init__(MENTION_RE, md)
|
||||
self.config = config
|
||||
|
||||
def handleMatch(self, m):
|
||||
from app.models import User
|
||||
|
||||
label = m.group(2)
|
||||
user = m.group(3)
|
||||
package_name = m.group(4)
|
||||
if package_name:
|
||||
el = ElementTree.Element("a")
|
||||
el.text = label
|
||||
el.set("href", url_for("packages.view", author=user, name=package_name))
|
||||
return el
|
||||
else:
|
||||
if User.query.filter_by(username=user).count() == 0:
|
||||
return None
|
||||
|
||||
el = ElementTree.Element("a")
|
||||
el.text = label
|
||||
el.set("href", url_for("users.profile", username=user))
|
||||
el.set("data-username", user)
|
||||
return el
|
||||
|
||||
|
||||
class MentionExtension(Extension):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MentionExtension, self).__init__(*args, **kwargs)
|
||||
|
||||
def extendMarkdown(self, md):
|
||||
md.ESCAPED_CHARS.append("@")
|
||||
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
|
||||
|
||||
|
||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension(), MentionExtension()]
|
||||
MARKDOWN_EXTENSION_CONFIG = {
|
||||
"fenced_code": {},
|
||||
"tables": {},
|
||||
"codehilite": {
|
||||
"guess_lang": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def init_markdown(app):
|
||||
global md
|
||||
|
||||
md = Markdown(extensions=MARKDOWN_EXTENSIONS,
|
||||
extension_configs=MARKDOWN_EXTENSION_CONFIG,
|
||||
output_format="html5")
|
||||
|
||||
@app.template_filter()
|
||||
def markdown(source):
|
||||
return Markup(render_markdown(source))
|
||||
|
||||
|
||||
def get_headings(html: str):
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
headings = soup.find_all(["h1", "h2", "h3"])
|
||||
|
||||
root = []
|
||||
stack = []
|
||||
for heading in headings:
|
||||
this = {"link": heading.get("id") or "", "text": heading.text, "children": []}
|
||||
this_level = int(heading.name[1:]) - 1
|
||||
|
||||
while this_level <= len(stack):
|
||||
stack.pop()
|
||||
|
||||
if len(stack) > 0:
|
||||
stack[-1]["children"].append(this)
|
||||
else:
|
||||
root.append(this)
|
||||
|
||||
stack.append(this)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def get_user_mentions(html: str) -> set:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
links = soup.select("a[data-username]")
|
||||
return set([x.get("data-username") for x in links])
|
|
@ -115,10 +115,10 @@ class ForumTopic(db.Model):
|
|||
topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
|
||||
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="forum_topics")
|
||||
author = db.relationship("User")
|
||||
|
||||
wip = db.Column(db.Boolean, default=False, nullable=False)
|
||||
discarded = db.Column(db.Boolean, default=False, nullable=False)
|
||||
wip = db.Column(db.Boolean, server_default="0")
|
||||
discarded = db.Column(db.Boolean, server_default="0")
|
||||
|
||||
type = db.Column(db.Enum(PackageType), nullable=False)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
|
|
|
@ -19,14 +19,12 @@ import datetime
|
|||
import enum
|
||||
|
||||
from flask import url_for
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_sqlalchemy import BaseQuery
|
||||
from sqlalchemy_searchable import SearchQueryMixin
|
||||
from sqlalchemy_utils.types import TSVectorType
|
||||
|
||||
from . import db
|
||||
from .users import Permission, UserRank, User
|
||||
from .. import app
|
||||
|
||||
|
||||
class PackageQuery(BaseQuery, SearchQueryMixin):
|
||||
|
@ -37,12 +35,10 @@ class License(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(50), nullable=False, unique=True)
|
||||
is_foss = db.Column(db.Boolean, nullable=False, default=True)
|
||||
url = db.Column(db.String(128), nullable=True, default=None)
|
||||
|
||||
def __init__(self, v: str, is_foss: bool = True, url: str = None):
|
||||
def __init__(self, v, is_foss=True):
|
||||
self.name = v
|
||||
self.is_foss = is_foss
|
||||
self.url = url
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -59,24 +55,6 @@ class PackageType(enum.Enum):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
if self == PackageType.MOD:
|
||||
return lazy_gettext("Mod")
|
||||
elif self == PackageType.GAME:
|
||||
return lazy_gettext("Game")
|
||||
elif self == PackageType.TXP:
|
||||
return lazy_gettext("Texture Pack")
|
||||
|
||||
@property
|
||||
def plural(self):
|
||||
if self == PackageType.MOD:
|
||||
return lazy_gettext("Mods")
|
||||
elif self == PackageType.GAME:
|
||||
return lazy_gettext("Games")
|
||||
elif self == PackageType.TXP:
|
||||
return lazy_gettext("Texture Packs")
|
||||
|
||||
@classmethod
|
||||
def get(cls, name):
|
||||
try:
|
||||
|
@ -86,72 +64,13 @@ class PackageType(enum.Enum):
|
|||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.text) for choice in cls]
|
||||
return [(choice, choice.value) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
return item if type(item) == PackageType else PackageType[item.upper()]
|
||||
|
||||
|
||||
class PackageDevState(enum.Enum):
|
||||
WIP = "Work in Progress"
|
||||
BETA = "Beta"
|
||||
ACTIVELY_DEVELOPED = "Actively Developed"
|
||||
MAINTENANCE_ONLY = "Maintenance Only"
|
||||
AS_IS = "As-Is"
|
||||
DEPRECATED = "Deprecated"
|
||||
LOOKING_FOR_MAINTAINER = "Looking for Maintainer"
|
||||
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_desc(self):
|
||||
if self == PackageDevState.WIP:
|
||||
return "Under active development, and may break worlds/things without warning"
|
||||
elif self == PackageDevState.BETA:
|
||||
return "Fully playable, but with some breakages/changes expected"
|
||||
elif self == PackageDevState.MAINTENANCE_ONLY:
|
||||
return "Finished, with bug fixes being made as needed"
|
||||
elif self == PackageDevState.AS_IS:
|
||||
return "Finished, the maintainer doesn't intend to continue working on it or provide support"
|
||||
elif self == PackageDevState.DEPRECATED:
|
||||
return "The maintainer doesn't recommend this package. See the description for more info"
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get(cls, name):
|
||||
try:
|
||||
return PackageDevState[name.upper()]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def choices(cls, with_none):
|
||||
def build_label(choice):
|
||||
desc = choice.get_desc()
|
||||
if desc is None:
|
||||
return choice.value
|
||||
else:
|
||||
return f"{choice.value}: {desc}"
|
||||
|
||||
ret = [(choice, build_label(choice)) for choice in cls]
|
||||
|
||||
if with_none:
|
||||
ret.insert(0, (None, ""))
|
||||
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
if item is None or (isinstance(item, str) and item.upper() == "NONE"):
|
||||
return None
|
||||
return item if type(item) == PackageDevState else PackageDevState[item.upper()]
|
||||
|
||||
|
||||
class PackageState(enum.Enum):
|
||||
WIP = "Draft"
|
||||
CHANGES_NEEDED = "Changes Needed"
|
||||
|
@ -164,30 +83,17 @@ class PackageState(enum.Enum):
|
|||
|
||||
def verb(self):
|
||||
if self == self.READY_FOR_REVIEW:
|
||||
return lazy_gettext("Submit for Approval")
|
||||
return "Submit for Review"
|
||||
elif self == self.APPROVED:
|
||||
return lazy_gettext("Approve")
|
||||
return "Approve"
|
||||
elif self == self.DELETED:
|
||||
return lazy_gettext("Delete")
|
||||
return "Delete"
|
||||
else:
|
||||
return self.value
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
if self == self.WIP:
|
||||
return "warning"
|
||||
elif self == self.CHANGES_NEEDED:
|
||||
return "danger"
|
||||
elif self == self.READY_FOR_REVIEW:
|
||||
return "success"
|
||||
elif self == self.APPROVED:
|
||||
return "info"
|
||||
else:
|
||||
return "danger"
|
||||
|
||||
@classmethod
|
||||
def get(cls, name):
|
||||
try:
|
||||
|
@ -237,7 +143,7 @@ class PackagePropertyKey(enum.Enum):
|
|||
return str(value)
|
||||
|
||||
|
||||
PackageProvides = db.Table("provides",
|
||||
provides = db.Table("provides",
|
||||
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
|
||||
db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
|
||||
)
|
||||
|
@ -344,25 +250,6 @@ class Dependency(db.Model):
|
|||
return retval
|
||||
|
||||
|
||||
class PackageGameSupport(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
|
||||
package = db.relationship("Package", foreign_keys=[package_id])
|
||||
|
||||
game_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
|
||||
game = db.relationship("Package", foreign_keys=[game_id])
|
||||
|
||||
supports = db.Column(db.Boolean, nullable=False, default=True)
|
||||
confidence = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
__table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),)
|
||||
|
||||
def __init__(self, package, game):
|
||||
self.package = package
|
||||
self.game = game
|
||||
|
||||
|
||||
class Package(db.Model):
|
||||
query_class = PackageQuery
|
||||
|
||||
|
@ -390,8 +277,7 @@ class Package(db.Model):
|
|||
media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
|
||||
media_license = db.relationship("License", foreign_keys=[media_license_id])
|
||||
|
||||
state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP)
|
||||
dev_state = db.Column(db.Enum(PackageDevState), nullable=True, default=None)
|
||||
state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP)
|
||||
|
||||
@property
|
||||
def approved(self):
|
||||
|
@ -409,18 +295,11 @@ class Package(db.Model):
|
|||
website = db.Column(db.String(200), nullable=True)
|
||||
issueTracker = db.Column(db.String(200), nullable=True)
|
||||
forums = db.Column(db.Integer, nullable=True)
|
||||
video_url = db.Column(db.String(200), nullable=True, default=None)
|
||||
|
||||
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages")
|
||||
provides = db.relationship("MetaPackage", secondary=provides, order_by=db.asc("name"), back_populates="packages")
|
||||
|
||||
dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
|
||||
|
||||
supported_games = db.relationship("PackageGameSupport", back_populates="package", lazy="dynamic",
|
||||
foreign_keys=[PackageGameSupport.package_id])
|
||||
|
||||
game_supported_mods = db.relationship("PackageGameSupport", back_populates="game", lazy="dynamic",
|
||||
foreign_keys=[PackageGameSupport.game_id])
|
||||
|
||||
tags = db.relationship("Tag", secondary=Tags, back_populates="packages")
|
||||
|
||||
content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages")
|
||||
|
@ -432,7 +311,7 @@ class Package(db.Model):
|
|||
lazy="dynamic", order_by=db.asc("package_screenshot_order"), cascade="all, delete, delete-orphan")
|
||||
|
||||
main_screenshot = db.relationship("PackageScreenshot", uselist=False, foreign_keys="PackageScreenshot.package_id",
|
||||
lazy=True, order_by=db.asc("package_screenshot_order"), viewonly=True,
|
||||
lazy=True, order_by=db.asc("package_screenshot_order"),
|
||||
primaryjoin="and_(Package.id==PackageScreenshot.package_id, PackageScreenshot.approved)")
|
||||
|
||||
cover_image_id = db.Column(db.Integer, db.ForeignKey("package_screenshot.id"), nullable=True, default=None)
|
||||
|
@ -443,8 +322,7 @@ class Package(db.Model):
|
|||
threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"),
|
||||
foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
|
||||
reviews = db.relationship("PackageReview", back_populates="package",
|
||||
order_by=[db.desc("package_review_score"),db.desc("package_review_created_at")],
|
||||
reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("package_review_created_at"),
|
||||
cascade="all, delete, delete-orphan")
|
||||
|
||||
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id",
|
||||
|
@ -459,9 +337,6 @@ class Package(db.Model):
|
|||
update_config = db.relationship("PackageUpdateConfig", uselist=False, back_populates="package",
|
||||
cascade="all, delete, delete-orphan")
|
||||
|
||||
aliases = db.relationship("PackageAlias", foreign_keys="PackageAlias.package_id",
|
||||
back_populates="package", cascade="all, delete, delete-orphan")
|
||||
|
||||
def __init__(self, package=None):
|
||||
if package is None:
|
||||
return
|
||||
|
@ -475,14 +350,6 @@ class Package(db.Model):
|
|||
for e in PackagePropertyKey:
|
||||
setattr(self, e.name, getattr(package, e.name))
|
||||
|
||||
@classmethod
|
||||
def get_by_key(cls, key):
|
||||
parts = key.split("/")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
return Package.query.filter(Package.name == parts[1], Package.author.has(username=parts[0])).first()
|
||||
|
||||
def getId(self):
|
||||
return "{}/{}".format(self.author.username, self.name)
|
||||
|
||||
|
@ -504,15 +371,10 @@ class Package(db.Model):
|
|||
def getSortedOptionalDependencies(self):
|
||||
return self.getSortedDependencies(False)
|
||||
|
||||
def getSortedSupportedGames(self):
|
||||
supported = self.supported_games.all()
|
||||
supported.sort(key=lambda x: -x.game.score)
|
||||
return supported
|
||||
|
||||
def getAsDictionaryKey(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"author": self.author.username,
|
||||
"author": self.author.display_name,
|
||||
"type": self.type.toName(),
|
||||
}
|
||||
|
||||
|
@ -523,26 +385,16 @@ class Package(db.Model):
|
|||
release = self.getDownloadRelease(version=version)
|
||||
release_id = release and release.id
|
||||
|
||||
short_desc = self.short_desc
|
||||
if self.dev_state == PackageDevState.WIP:
|
||||
short_desc = "Work in Progress. " + self.short_desc
|
||||
|
||||
ret = {
|
||||
return {
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"author": self.author.username,
|
||||
"short_description": short_desc,
|
||||
"short_description": self.short_desc,
|
||||
"type": self.type.toName(),
|
||||
"release": release_id,
|
||||
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
|
||||
"aliases": [ alias.getAsDictionary() for alias in self.aliases ],
|
||||
"thumbnail": (base_url + tnurl) if tnurl is not None else None
|
||||
}
|
||||
|
||||
if not ret["aliases"]:
|
||||
del ret["aliases"]
|
||||
|
||||
return ret
|
||||
|
||||
def getAsDictionary(self, base_url, version=None):
|
||||
tnurl = self.getThumbnailURL(1)
|
||||
release = self.getDownloadRelease(version=version)
|
||||
|
@ -551,7 +403,6 @@ class Package(db.Model):
|
|||
"maintainers": [x.username for x in self.maintainers],
|
||||
|
||||
"state": self.state.name,
|
||||
"dev_state": self.dev_state.name if self.dev_state else None,
|
||||
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
|
@ -567,7 +418,6 @@ class Package(db.Model):
|
|||
"website": self.website,
|
||||
"issue_tracker": self.issueTracker,
|
||||
"forums": self.forums,
|
||||
"video_url": self.video_url,
|
||||
|
||||
"tags": [x.name for x in self.tags],
|
||||
"content_warnings": [x.name for x in self.content_warnings],
|
||||
|
@ -576,19 +426,11 @@ class Package(db.Model):
|
|||
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
|
||||
"screenshots": [base_url + ss.url for ss in self.screenshots],
|
||||
|
||||
"url": base_url + self.getURL("packages.download"),
|
||||
"url": base_url + self.getDownloadURL(),
|
||||
"release": release and release.id,
|
||||
|
||||
"score": round(self.score * 10) / 10,
|
||||
"downloads": self.downloads,
|
||||
|
||||
"game_support": [
|
||||
{
|
||||
"supports": support.supports,
|
||||
"confidence": support.confidence,
|
||||
"game": support.game.getAsDictionaryShort(base_url, version)
|
||||
} for support in self.supported_games.all()
|
||||
]
|
||||
"downloads": self.downloads
|
||||
}
|
||||
|
||||
def getThumbnailOrPlaceholder(self, level=2):
|
||||
|
@ -609,12 +451,14 @@ class Package(db.Model):
|
|||
else:
|
||||
return screenshot.url
|
||||
|
||||
def getURL(self, endpoint, absolute=False, **kwargs):
|
||||
def getDetailsURL(self, absolute=False):
|
||||
if absolute:
|
||||
from app.utils import abs_url_for
|
||||
return abs_url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
|
||||
return abs_url_for("packages.view",
|
||||
author=self.author.username, name=self.name)
|
||||
else:
|
||||
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
|
||||
return url_for("packages.view",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getShieldURL(self, type):
|
||||
from app.utils import abs_url_for
|
||||
|
@ -623,7 +467,15 @@ class Package(db.Model):
|
|||
|
||||
def makeShield(self, type):
|
||||
return "[![ContentDB]({})]({})" \
|
||||
.format(self.getShieldURL(type), self.getURL("packages.view", True))
|
||||
.format(self.getShieldURL(type), self.getDetailsURL(True))
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("packages.create_edit",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getReleaseListURL(self):
|
||||
return url_for("packages.list_releases",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getSetStateURL(self, state):
|
||||
if type(state) == str:
|
||||
|
@ -634,6 +486,58 @@ class Package(db.Model):
|
|||
return url_for("packages.move_to_state",
|
||||
author=self.author.username, name=self.name, state=state.name.lower())
|
||||
|
||||
def getRemoveURL(self):
|
||||
return url_for("packages.remove",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getNewScreenshotURL(self):
|
||||
return url_for("packages.create_screenshot",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getEditScreenshotsURL(self):
|
||||
return url_for("packages.screenshots",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getCreateReleaseURL(self, **kwargs):
|
||||
return url_for("packages.create_release",
|
||||
author=self.author.username, name=self.name, **kwargs)
|
||||
|
||||
def getBulkReleaseURL(self):
|
||||
return url_for("packages.bulk_change_release",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getUpdateConfigURL(self, trigger=None, action=None):
|
||||
return url_for("packages.update_config",
|
||||
author=self.author.username, name=self.name, trigger=trigger, action=action)
|
||||
|
||||
def getSetupReleasesURL(self):
|
||||
return url_for("packages.setup_releases",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getDownloadURL(self):
|
||||
return url_for("packages.download",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getEditMaintainersURL(self):
|
||||
return url_for("packages.edit_maintainers",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getRemoveSelfMaintainerURL(self):
|
||||
return url_for("packages.remove_self_maintainers",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getUpdateFromReleaseURL(self):
|
||||
return url_for("packages.update_from_release",
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getReviewURL(self):
|
||||
return url_for('packages.review',
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getAuditLogURL(self):
|
||||
return url_for('packages.audit',
|
||||
author=self.author.username, name=self.name)
|
||||
|
||||
def getDownloadRelease(self, version=None):
|
||||
for rel in self.releases:
|
||||
if rel.approved and (version is None or
|
||||
|
@ -654,7 +558,6 @@ class Package(db.Model):
|
|||
|
||||
isOwner = user == self.author
|
||||
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
|
||||
isApprover = user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
if perm == Permission.CREATE_THREAD:
|
||||
return user.rank.atLeast(UserRank.MEMBER)
|
||||
|
@ -663,33 +566,33 @@ class Package(db.Model):
|
|||
elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
|
||||
return isMaintainer
|
||||
|
||||
elif perm == Permission.EDIT_PACKAGE:
|
||||
elif perm == Permission.EDIT_PACKAGE or \
|
||||
perm == Permission.APPROVE_CHANGES or perm == Permission.APPROVE_RELEASE:
|
||||
return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
|
||||
elif perm == Permission.APPROVE_RELEASE:
|
||||
return (isMaintainer or isApprover) and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
|
||||
# Anyone can change the package name when not approved, but only editors when approved
|
||||
elif perm == Permission.CHANGE_NAME:
|
||||
return not self.approved or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
# Editors can change authors and approve new packages
|
||||
elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
|
||||
return isApprover
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
elif perm == Permission.APPROVE_SCREENSHOT:
|
||||
return (isMaintainer or isApprover) and \
|
||||
user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
return isMaintainer and user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
|
||||
elif perm == Permission.EDIT_MAINTAINERS or perm == Permission.DELETE_PACKAGE:
|
||||
return isOwner or user.rank.atLeast(UserRank.EDITOR)
|
||||
elif perm == Permission.EDIT_MAINTAINERS:
|
||||
return isOwner or user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
elif perm == Permission.UNAPPROVE_PACKAGE:
|
||||
return isOwner or user.rank.atLeast(UserRank.APPROVER)
|
||||
elif perm == Permission.UNAPPROVE_PACKAGE or perm == Permission.DELETE_PACKAGE:
|
||||
return user.rank.atLeast(UserRank.MEMBER if isOwner else UserRank.EDITOR)
|
||||
|
||||
elif perm == Permission.CHANGE_RELEASE_URL:
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
elif perm == Permission.REIMPORT_META:
|
||||
return user.rank.atLeast(UserRank.ADMIN)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to packages".format(perm.name))
|
||||
|
||||
|
@ -715,10 +618,9 @@ class Package(db.Model):
|
|||
return False
|
||||
|
||||
if state == PackageState.READY_FOR_REVIEW or state == PackageState.APPROVED:
|
||||
if state == PackageState.APPROVED and not self.checkPerm(user, Permission.APPROVE_NEW):
|
||||
return False
|
||||
requiredPerm = Permission.APPROVE_NEW if state == PackageState.APPROVED else Permission.EDIT_PACKAGE
|
||||
|
||||
if not (self.checkPerm(user, Permission.APPROVE_NEW) or self.checkPerm(user, Permission.EDIT_PACKAGE)):
|
||||
if not self.checkPerm(user, requiredPerm):
|
||||
return False
|
||||
|
||||
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
|
||||
|
@ -730,8 +632,7 @@ class Package(db.Model):
|
|||
needsScreenshot = \
|
||||
(self.type == self.type.GAME or self.type == self.type.TXP) and \
|
||||
self.screenshots.count() == 0
|
||||
|
||||
return self.releases.filter(PackageRelease.task_id.is_(None)).count() > 0 and not needsScreenshot
|
||||
return self.releases.count() > 0 and not needsScreenshot
|
||||
|
||||
elif state == PackageState.CHANGES_NEEDED:
|
||||
return self.checkPerm(user, Permission.APPROVE_NEW)
|
||||
|
@ -770,7 +671,7 @@ class MetaPackage(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
dependencies = db.relationship("Dependency", back_populates="meta_package", lazy="dynamic")
|
||||
packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=PackageProvides)
|
||||
packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=provides)
|
||||
|
||||
mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
|
||||
|
||||
|
@ -847,7 +748,6 @@ class Tag(db.Model):
|
|||
backgroundColor = db.Column(db.String(6), nullable=False)
|
||||
textColor = db.Column(db.String(6), nullable=False)
|
||||
views = db.Column(db.Integer, nullable=False, default=0)
|
||||
is_protected = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
packages = db.relationship("Package", back_populates="tags", secondary=Tags)
|
||||
|
||||
|
@ -862,13 +762,7 @@ class Tag(db.Model):
|
|||
|
||||
def getAsDictionary(self):
|
||||
description = self.description if self.description != "" else None
|
||||
return {
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"description": description,
|
||||
"is_protected": self.is_protected,
|
||||
"views": self.views,
|
||||
}
|
||||
return { "name": self.name, "title": self.title, "description": description }
|
||||
|
||||
|
||||
class MinetestRelease(db.Model):
|
||||
|
@ -936,10 +830,6 @@ class PackageRelease(db.Model):
|
|||
# If the release is approved, then the task_id must be null and the url must be present
|
||||
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
|
||||
def getAsDictionary(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
|
@ -1033,7 +923,7 @@ class PackageRelease(db.Model):
|
|||
|
||||
return count > 0
|
||||
elif perm == Permission.APPROVE_RELEASE:
|
||||
return user.rank.atLeast(UserRank.APPROVER) or \
|
||||
return user.rank.atLeast(UserRank.EDITOR) or \
|
||||
(isMaintainer and user.rank.atLeast(
|
||||
UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER))
|
||||
else:
|
||||
|
@ -1041,9 +931,6 @@ class PackageRelease(db.Model):
|
|||
|
||||
|
||||
class PackageScreenshot(db.Model):
|
||||
HARD_MIN_SIZE = (920, 517)
|
||||
SOFT_MIN_SIZE = (1280, 720)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
|
||||
|
@ -1055,22 +942,6 @@ class PackageScreenshot(db.Model):
|
|||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
width = db.Column(db.Integer, nullable=False)
|
||||
height = db.Column(db.Integer, nullable=False)
|
||||
|
||||
def is_very_small(self):
|
||||
return self.width < 720 or self.height < 405
|
||||
|
||||
def is_too_small(self):
|
||||
return self.width < PackageScreenshot.HARD_MIN_SIZE[0] or self.height < PackageScreenshot.HARD_MIN_SIZE[1]
|
||||
|
||||
def is_low_res(self):
|
||||
return self.width < PackageScreenshot.SOFT_MIN_SIZE[0] or self.height < PackageScreenshot.SOFT_MIN_SIZE[1]
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("packages.edit_screenshot",
|
||||
author=self.package.author.username,
|
||||
|
@ -1092,11 +963,8 @@ class PackageScreenshot(db.Model):
|
|||
"order": self.order,
|
||||
"title": self.title,
|
||||
"url": base_url + self.url,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"approved": self.approved,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"is_cover_image": self.package.cover_image == self,
|
||||
}
|
||||
|
||||
|
||||
|
@ -1167,25 +1035,4 @@ class PackageUpdateConfig(db.Model):
|
|||
return self.last_tag or self.last_commit
|
||||
|
||||
def get_create_release_url(self):
|
||||
return self.package.getURL("packages.create_release", title=self.get_title(), ref=self.get_ref())
|
||||
|
||||
|
||||
class PackageAlias(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
|
||||
package = db.relationship("Package", back_populates="aliases", foreign_keys=[package_id])
|
||||
|
||||
author = db.Column(db.String(50), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
|
||||
def __init__(self, author="", name=""):
|
||||
self.author = author
|
||||
self.name = name
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("packages.alias_create_edit", author=self.package.author.username,
|
||||
name=self.package.name, alias_id=self.id)
|
||||
|
||||
def getAsDictionary(self):
|
||||
return f"{self.author}/{self.name}"
|
||||
return self.package.getCreateReleaseURL(title=self.get_title(), ref=self.get_ref())
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
from typing import Tuple, List
|
||||
|
||||
from flask import url_for
|
||||
|
||||
|
@ -23,6 +22,7 @@ from . import db
|
|||
from .users import Permission, UserRank
|
||||
from .packages import Package
|
||||
|
||||
|
||||
watchers = db.Table("watchers",
|
||||
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
|
||||
db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
|
||||
|
@ -55,19 +55,8 @@ class Thread(db.Model):
|
|||
|
||||
watchers = db.relationship("User", secondary=watchers, backref="watching")
|
||||
|
||||
def get_description(self):
|
||||
comment = self.replies[0].comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
|
||||
if len(comment) > 100:
|
||||
return comment[:97] + "..."
|
||||
else:
|
||||
return comment
|
||||
|
||||
def getViewURL(self, absolute=False):
|
||||
if absolute:
|
||||
from ..utils import abs_url_for
|
||||
return abs_url_for("threads.view", id=self.id)
|
||||
else:
|
||||
return url_for("threads.view", id=self.id, _external=False)
|
||||
def getViewURL(self):
|
||||
return url_for("threads.view", id=self.id, _external=False)
|
||||
|
||||
def getSubscribeURL(self):
|
||||
return url_for("threads.subscribe", id=self.id)
|
||||
|
@ -88,7 +77,7 @@ class Thread(db.Model):
|
|||
if self.package:
|
||||
isMaintainer = isMaintainer or user in self.package.maintainers
|
||||
|
||||
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
|
||||
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
if perm == Permission.SEE_THREAD:
|
||||
return canSee
|
||||
|
@ -96,14 +85,9 @@ class Thread(db.Model):
|
|||
elif perm == Permission.COMMENT_THREAD:
|
||||
return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
|
||||
|
||||
elif perm == Permission.LOCK_THREAD:
|
||||
elif perm == Permission.LOCK_THREAD or perm == Permission.DELETE_THREAD:
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
elif perm == Permission.DELETE_THREAD:
|
||||
from app.utils.models import get_system_user
|
||||
return (self.author == get_system_user() and self.package and
|
||||
user in self.package.maintainers) or user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
||||
|
||||
|
@ -124,9 +108,6 @@ class ThreadReply(db.Model):
|
|||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def get_url(self):
|
||||
return url_for('threads.view', id=self.thread.id) + "#reply-" + str(self.id)
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
@ -137,7 +118,7 @@ class ThreadReply(db.Model):
|
|||
raise Exception("Unknown permission given to ThreadReply.checkPerm()")
|
||||
|
||||
if perm == Permission.EDIT_REPLY:
|
||||
return user.rank.atLeast(UserRank.MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
|
||||
return user == self.author and user.rank.atLeast(UserRank.MEMBER) and not self.thread.locked
|
||||
|
||||
elif perm == Permission.DELETE_REPLY:
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
|
||||
|
@ -160,81 +141,14 @@ class PackageReview(db.Model):
|
|||
recommends = db.Column(db.Boolean, nullable=False)
|
||||
|
||||
thread = db.relationship("Thread", uselist=False, back_populates="review")
|
||||
votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan")
|
||||
|
||||
score = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
def get_totals(self, current_user = None) -> Tuple[int,int,bool]:
|
||||
votes: List[PackageReviewVote] = self.votes
|
||||
pos = sum([ 1 for vote in votes if vote.is_positive ])
|
||||
neg = sum([ 1 for vote in votes if not vote.is_positive])
|
||||
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
|
||||
return pos, neg, user_vote.is_positive if user_vote else None
|
||||
|
||||
def getAsDictionary(self, include_package=False):
|
||||
pos, neg, _user = self.get_totals()
|
||||
ret = {
|
||||
"is_positive": self.recommends,
|
||||
"user": {
|
||||
"username": self.author.username,
|
||||
"display_name": self.author.display_name,
|
||||
},
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"votes": {
|
||||
"helpful": pos,
|
||||
"unhelpful": neg,
|
||||
},
|
||||
"title": self.thread.title,
|
||||
"comment": self.thread.replies[0].comment,
|
||||
}
|
||||
if include_package:
|
||||
ret["package"] = self.package.getAsDictionaryKey()
|
||||
return ret
|
||||
|
||||
def asSign(self):
|
||||
return 1 if self.recommends else -1
|
||||
|
||||
def getEditURL(self):
|
||||
return self.package.getURL("packages.review")
|
||||
return self.package.getReviewURL()
|
||||
|
||||
def getDeleteURL(self):
|
||||
return url_for("packages.delete_review",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
reviewer=self.author.username)
|
||||
|
||||
def getVoteUrl(self, next_url=None):
|
||||
return url_for("packages.review_vote",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
review_id=self.id,
|
||||
r=next_url)
|
||||
|
||||
def update_score(self):
|
||||
(pos, neg, _) = self.get_totals()
|
||||
self.score = 3 * (pos - neg) + 1
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to PackageReview.checkPerm()")
|
||||
|
||||
if perm == Permission.DELETE_REVIEW:
|
||||
return user == self.author or user.rank.atLeast(UserRank.MODERATOR)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to reviews".format(perm.name))
|
||||
|
||||
|
||||
class PackageReviewVote(db.Model):
|
||||
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
|
||||
review = db.relationship("PackageReview", foreign_keys=[review_id], back_populates="votes")
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
|
||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="review_votes")
|
||||
|
||||
is_positive = db.Column(db.Boolean, nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
name=self.package.name)
|
||||
|
|
|
@ -31,11 +31,10 @@ class UserRank(enum.Enum):
|
|||
NEW_MEMBER = 2
|
||||
MEMBER = 3
|
||||
TRUSTED_MEMBER = 4
|
||||
APPROVER = 5
|
||||
EDITOR = 6
|
||||
BOT = 7
|
||||
MODERATOR = 8
|
||||
ADMIN = 9
|
||||
EDITOR = 5
|
||||
BOT = 6
|
||||
MODERATOR = 7
|
||||
ADMIN = 8
|
||||
|
||||
def atLeast(self, min):
|
||||
return self.value >= min.value
|
||||
|
@ -60,12 +59,14 @@ class UserRank(enum.Enum):
|
|||
|
||||
class Permission(enum.Enum):
|
||||
EDIT_PACKAGE = "EDIT_PACKAGE"
|
||||
APPROVE_CHANGES = "APPROVE_CHANGES"
|
||||
DELETE_PACKAGE = "DELETE_PACKAGE"
|
||||
CHANGE_AUTHOR = "CHANGE_AUTHOR"
|
||||
CHANGE_NAME = "CHANGE_NAME"
|
||||
MAKE_RELEASE = "MAKE_RELEASE"
|
||||
DELETE_RELEASE = "DELETE_RELEASE"
|
||||
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
|
||||
REIMPORT_META = "REIMPORT_META"
|
||||
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
|
||||
APPROVE_RELEASE = "APPROVE_RELEASE"
|
||||
APPROVE_NEW = "APPROVE_NEW"
|
||||
|
@ -86,7 +87,6 @@ class Permission(enum.Enum):
|
|||
TOPIC_DISCARD = "TOPIC_DISCARD"
|
||||
CREATE_TOKEN = "CREATE_TOKEN"
|
||||
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
|
||||
DELETE_REVIEW = "DELETE_REVIEW"
|
||||
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
|
||||
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
|
||||
|
||||
|
@ -97,14 +97,13 @@ class Permission(enum.Enum):
|
|||
return False
|
||||
|
||||
if self == Permission.APPROVE_NEW or \
|
||||
self == Permission.APPROVE_CHANGES or \
|
||||
self == Permission.APPROVE_RELEASE or \
|
||||
self == Permission.APPROVE_SCREENSHOT or \
|
||||
self == Permission.EDIT_TAGS or \
|
||||
self == Permission.CREATE_TAG or \
|
||||
self == Permission.SEE_THREAD:
|
||||
return user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
else:
|
||||
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
|
||||
|
||||
|
@ -125,8 +124,6 @@ def display_name_default(context):
|
|||
class User(db.Model, UserMixin):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow)
|
||||
|
||||
# User authentication information
|
||||
username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
|
||||
password = db.Column(db.String(255), nullable=True, server_default=None)
|
||||
|
@ -148,8 +145,6 @@ class User(db.Model, UserMixin):
|
|||
email = db.Column(db.String(255), nullable=True, unique=True)
|
||||
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
|
||||
|
||||
locale = db.Column(db.String(10), nullable=True, default=None)
|
||||
|
||||
# User information
|
||||
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
|
||||
is_active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
|
||||
|
@ -177,11 +172,9 @@ class User(db.Model, UserMixin):
|
|||
|
||||
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
|
||||
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
|
||||
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
|
||||
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
|
||||
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
|
||||
def __init__(self, username=None, active=False, email=None, password=None):
|
||||
self.username = username
|
||||
|
@ -193,7 +186,8 @@ class User(db.Model, UserMixin):
|
|||
|
||||
def canAccessTodoList(self):
|
||||
return Permission.APPROVE_NEW.check(self) or \
|
||||
Permission.APPROVE_RELEASE.check(self)
|
||||
Permission.APPROVE_RELEASE.check(self) or \
|
||||
Permission.APPROVE_CHANGES.check(self)
|
||||
|
||||
def isClaimed(self):
|
||||
return self.rank.atLeast(UserRank.NEW_MEMBER)
|
||||
|
@ -204,7 +198,7 @@ class User(db.Model, UserMixin):
|
|||
elif self.rank == UserRank.BOT:
|
||||
return "/static/bot_avatar.png"
|
||||
else:
|
||||
return gravatar(self.email or f"{self.username}@content.minetest.net")
|
||||
return gravatar(self.email or "")
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
|
@ -218,12 +212,10 @@ class User(db.Model, UserMixin):
|
|||
# Members can edit their own packages, and editors can edit any packages
|
||||
if perm == Permission.CHANGE_AUTHOR:
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
elif perm == Permission.CHANGE_USERNAMES:
|
||||
elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_USERNAMES:
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
elif perm == Permission.CHANGE_RANK:
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)
|
||||
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
|
||||
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank))
|
||||
return user == self or user.rank.atLeast(UserRank.ADMIN)
|
||||
elif perm == Permission.CHANGE_DISPLAY_NAME:
|
||||
return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR)
|
||||
elif perm == Permission.CREATE_TOKEN:
|
||||
|
@ -295,7 +287,6 @@ class UserEmailVerification(db.Model):
|
|||
token = db.Column(db.String(32), nullable=True)
|
||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="email_verifications")
|
||||
is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class EmailSubscription(db.Model):
|
||||
|
|
Before Width: | Height: | Size: 980 B After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 20 KiB |
|
@ -1,6 +1,3 @@
|
|||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
$("textarea.markdown").each(function() {
|
||||
async function render(plainText, preview) {
|
||||
const response = await fetch(new Request("/api/markdown/", {
|
||||
|
@ -17,53 +14,11 @@ $("textarea.markdown").each(function() {
|
|||
|
||||
let timeout_id = null;
|
||||
|
||||
function urlInserter(url) {
|
||||
return (editor) => {
|
||||
var cm = editor.codemirror;
|
||||
var stat = getState(cm);
|
||||
var options = editor.options;
|
||||
_replaceSelection(cm, stat.table, `[](${url})`);
|
||||
};
|
||||
}
|
||||
|
||||
this.easy_mde = new EasyMDE({
|
||||
element: this,
|
||||
hideIcons: ["image"],
|
||||
showIcons: ["code", "table"],
|
||||
forceSync: true,
|
||||
toolbar: [
|
||||
"bold",
|
||||
"italic",
|
||||
"heading",
|
||||
"|",
|
||||
"code",
|
||||
"quote",
|
||||
"unordered-list",
|
||||
"ordered-list",
|
||||
"|",
|
||||
"link",
|
||||
"table",
|
||||
"|",
|
||||
"preview",
|
||||
"side-by-side",
|
||||
"fullscreen",
|
||||
"|",
|
||||
"guide",
|
||||
// {
|
||||
// name: "rules",
|
||||
// className: "fa fa-book",
|
||||
// title: "others buttons",
|
||||
// children: [
|
||||
// {
|
||||
// name: "rules",
|
||||
// action: urlInserter("/policy_and_guidance/#2-accepted-content"),
|
||||
// className: "fa fa-star",
|
||||
// title: "2. Accepted content",
|
||||
// text: "2. Accepted content",
|
||||
// },
|
||||
// ]
|
||||
// },
|
||||
],
|
||||
previewRender: (plainText, preview) => {
|
||||
if (timeout_id) {
|
||||
clearTimeout(timeout_id);
|
||||
|
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 22 KiB |
|
@ -1,6 +1,3 @@
|
|||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
const min = $("#min_rel");
|
||||
const max = $("#max_rel");
|
||||
const none = $("#min_rel option:first-child").attr("value");
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
$(".topic-discard").click(function() {
|
||||
const ele = $(this);
|
||||
const tid = ele.attr("data-tid");
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
document.querySelectorAll(".video-embed").forEach(ele => {
|
||||
try {
|
||||
const href = ele.getAttribute("href");
|
||||
const url = new URL(href);
|
||||
|
||||
if (url.host == "www.youtube.com") {
|
||||
ele.addEventListener("click", () => {
|
||||
ele.parentNode.classList.add("d-block");
|
||||
ele.classList.add("embed-responsive");
|
||||
ele.classList.add("embed-responsive-16by9");
|
||||
ele.innerHTML = `
|
||||
<iframe title="YouTube video player" frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen>
|
||||
</iframe>`;
|
||||
|
||||
const embedURL = new URL("https://www.youtube.com/");
|
||||
embedURL.pathname = "/embed/" + url.searchParams.get("v");
|
||||
embedURL.searchParams.set("autoplay", "1");
|
||||
|
||||
const iframe = ele.children[0];
|
||||
iframe.setAttribute("src", embedURL);
|
||||
});
|
||||
|
||||
ele.setAttribute("data-src", href);
|
||||
ele.removeAttribute("href");
|
||||
|
||||
ele.querySelector(".label").innerText = "YouTube";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(url);
|
||||
return;
|
||||
}
|
||||
});
|
|
@ -3,8 +3,7 @@ from sqlalchemy import or_
|
|||
from sqlalchemy.orm import subqueryload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, \
|
||||
ContentWarning, PackageState, PackageDevState
|
||||
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, ContentWarning, PackageState
|
||||
from .utils import isYes, get_int_or_abort
|
||||
|
||||
|
||||
|
@ -21,7 +20,7 @@ class QueryBuilder:
|
|||
types = [PackageType.get(tname) for tname in types]
|
||||
types = [type for type in types if type is not None]
|
||||
if len(types) > 0:
|
||||
title = ", ".join([str(type.plural) for type in types])
|
||||
title = ", ".join([type.value + "s" for type in types])
|
||||
|
||||
# Get tags types
|
||||
tags = args.getlist("tag")
|
||||
|
@ -29,7 +28,8 @@ class QueryBuilder:
|
|||
tags = [tag for tag in tags if tag is not None]
|
||||
|
||||
# Hide
|
||||
self.hide_flags = set(args.getlist("hide"))
|
||||
hide_flags = args.getlist("hide")
|
||||
|
||||
|
||||
self.title = title
|
||||
self.types = types
|
||||
|
@ -37,24 +37,13 @@ class QueryBuilder:
|
|||
|
||||
self.random = "random" 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 None
|
||||
self.order_by = args.get("sort")
|
||||
self.order_dir = args.get("order") or "desc"
|
||||
|
||||
if "android_default" in self.hide_flags:
|
||||
self.hide_flags.update(["*", "deprecated"])
|
||||
self.hide_flags.discard("android_default")
|
||||
|
||||
if "desktop_default" in self.hide_flags:
|
||||
self.hide_flags.update(["deprecated"])
|
||||
self.hide_flags.discard("desktop_default")
|
||||
|
||||
self.hide_nonfree = "nonfree" in self.hide_flags
|
||||
self.hide_wip = "wip" in self.hide_flags
|
||||
self.hide_deprecated = "deprecated" in self.hide_flags
|
||||
self.hide_nonfree = "nonfree" in hide_flags
|
||||
self.hide_flags = set(hide_flags)
|
||||
self.hide_flags.discard("nonfree")
|
||||
self.hide_flags.discard("wip")
|
||||
self.hide_flags.discard("deprecated")
|
||||
|
||||
# Filters
|
||||
self.search = args.get("q")
|
||||
|
@ -75,10 +64,6 @@ class QueryBuilder:
|
|||
if self.search is not None and self.search.strip() == "":
|
||||
self.search = None
|
||||
|
||||
self.game = args.get("game")
|
||||
if self.game:
|
||||
self.game = Package.get_by_key(self.game)
|
||||
|
||||
def setSortIfNone(self, name, dir="desc"):
|
||||
if self.order_by is None:
|
||||
self.order_by = name
|
||||
|
@ -116,7 +101,7 @@ class QueryBuilder:
|
|||
else:
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
|
||||
query = query.options(subqueryload(Package.main_screenshot), subqueryload(Package.aliases))
|
||||
query = query.options(subqueryload(Package.main_screenshot))
|
||||
|
||||
query = self.orderPackageQuery(self.filterPackageQuery(query))
|
||||
|
||||
|
@ -136,13 +121,10 @@ class QueryBuilder:
|
|||
|
||||
query = query.filter_by(author=author)
|
||||
|
||||
if self.game:
|
||||
query = query.filter(Package.supported_games.any(game=self.game))
|
||||
|
||||
for tag in self.tags:
|
||||
query = query.filter(Package.tags.any(Tag.id == tag.id))
|
||||
|
||||
if "*" in self.hide_flags:
|
||||
if "android_default" in self.hide_flags:
|
||||
query = query.filter(~ Package.content_warnings.any())
|
||||
else:
|
||||
for flag in self.hide_flags:
|
||||
|
@ -154,11 +136,6 @@ class QueryBuilder:
|
|||
query = query.filter(Package.license.has(License.is_foss == True))
|
||||
query = query.filter(Package.media_license.has(License.is_foss == True))
|
||||
|
||||
if self.hide_wip:
|
||||
query = query.filter(or_(Package.dev_state == None, Package.dev_state != PackageDevState.WIP))
|
||||
if self.hide_deprecated:
|
||||
query = query.filter(or_(Package.dev_state == None, Package.dev_state != PackageDevState.DEPRECATED))
|
||||
|
||||
if self.version:
|
||||
query = query.join(Package.releases) \
|
||||
.filter(PackageRelease.approved == True) \
|
||||
|
|
43
app/sass.py
|
@ -12,16 +12,16 @@ Code unabashedly adapted from https://github.com/weapp/flask-coffee2js
|
|||
import os
|
||||
import os.path
|
||||
import codecs
|
||||
import sass
|
||||
from flask import send_from_directory
|
||||
from flask import *
|
||||
from scss import Scss
|
||||
|
||||
|
||||
def _convert(dir_path, src, dst):
|
||||
def _convert(dir, src, dst):
|
||||
original_wd = os.getcwd()
|
||||
os.chdir(dir_path)
|
||||
os.chdir(dir)
|
||||
|
||||
css = Scss()
|
||||
source = codecs.open(src, 'r', encoding='utf-8').read()
|
||||
output = sass.compile(string=source)
|
||||
output = css.compile(source)
|
||||
|
||||
os.chdir(original_wd)
|
||||
|
||||
|
@ -29,9 +29,8 @@ def _convert(dir_path, src, dst):
|
|||
outfile.write(output)
|
||||
outfile.close()
|
||||
|
||||
|
||||
def _get_dir_path(app, original_path, create=False):
|
||||
path = original_path
|
||||
def _getDirPath(app, originalPath, create=False):
|
||||
path = originalPath
|
||||
|
||||
if not os.path.isdir(path):
|
||||
path = os.path.join(app.root_path, path)
|
||||
|
@ -40,25 +39,25 @@ def _get_dir_path(app, original_path, create=False):
|
|||
if create:
|
||||
os.mkdir(path)
|
||||
else:
|
||||
raise IOError("Unable to find " + original_path)
|
||||
raise IOError("Unable to find " + originalPath)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def init_app(app, input_dir='scss', dest='static', force=False, cache_dir="public/static"):
|
||||
input_dir = _get_dir_path(app, input_dir)
|
||||
cache_dir = _get_dir_path(app, cache_dir or dest, True)
|
||||
def sass(app, inputDir='scss', outputPath='static', force=False, cacheDir="public/static"):
|
||||
static_url_path = app.static_url_path
|
||||
inputDir = _getDirPath(app, inputDir)
|
||||
cacheDir = _getDirPath(app, cacheDir or outputPath, True)
|
||||
|
||||
def _sass(filepath):
|
||||
scss_file = "%s/%s.scss" % (input_dir, filepath)
|
||||
cache_file = "%s/%s.css" % (cache_dir, filepath)
|
||||
sassfile = "%s/%s.scss" % (inputDir, filepath)
|
||||
cacheFile = "%s/%s.css" % (cacheDir, filepath)
|
||||
|
||||
# Source file exists, and needs regenerating
|
||||
if os.path.isfile(scss_file) and (force or not os.path.isfile(cache_file) or
|
||||
os.path.getmtime(scss_file) > os.path.getmtime(cache_file)):
|
||||
_convert(input_dir, scss_file, cache_file)
|
||||
app.logger.debug('Compiled %s into %s' % (scss_file, cache_file))
|
||||
if os.path.isfile(sassfile) and (force or not os.path.isfile(cacheFile) or
|
||||
os.path.getmtime(sassfile) > os.path.getmtime(cacheFile)):
|
||||
_convert(inputDir, sassfile, cacheFile)
|
||||
app.logger.debug('Compiled %s into %s' % (sassfile, cacheFile))
|
||||
|
||||
return send_from_directory(cache_dir, filepath + ".css")
|
||||
return send_from_directory(cacheDir, filepath + ".css")
|
||||
|
||||
app.add_url_rule("/%s/<path:filepath>.css" % dest, 'sass', _sass)
|
||||
app.add_url_rule("/%s/<path:filepath>.css" % outputPath, 'sass', _sass)
|
||||
|
|
|
@ -70,7 +70,7 @@
|
|||
}
|
||||
|
||||
.NOT_JOINED a, .NOT_JOINED {
|
||||
color: #aaa !important;
|
||||
color: #7ac !important;
|
||||
}
|
||||
|
||||
.ADMIN a, .ADMIN{
|
||||
|
@ -81,10 +81,6 @@
|
|||
color: #e90 !important;
|
||||
}
|
||||
|
||||
.APPROVER a, .APPROVER {
|
||||
color: #69f !important;
|
||||
}
|
||||
|
||||
.EDITOR a, .EDITOR {
|
||||
color: #b6f !important;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
@import "components.scss";
|
||||
@import "packages.scss";
|
||||
@import "gallery.scss";
|
||||
@import "packagegrid.scss";
|
||||
@import "comments.scss";
|
||||
|
||||
|
@ -56,13 +55,6 @@ a:hover .badge-notify {
|
|||
color: black;
|
||||
}
|
||||
|
||||
.badge-emoji {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
p, .content li {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased !important;
|
||||
|
@ -74,13 +66,9 @@ p, .content li {
|
|||
|
||||
.markdown {
|
||||
word-break: break-word;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
pre code {
|
||||
display: block;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(51, 51, 51, 0.25);
|
||||
|
@ -174,33 +162,4 @@ pre {
|
|||
}
|
||||
}
|
||||
|
||||
#featuredCarousel {
|
||||
.embed-responsive-item {
|
||||
filter: brightness(0.85);
|
||||
object-fit: cover;
|
||||
}
|
||||
.carousel-item, .embed-responsive-item {
|
||||
max-height: 50vh;
|
||||
}
|
||||
.carousel-inner {
|
||||
background-color: #000;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.carousel-indicators {
|
||||
margin-bottom: 0 !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
#featuredCarousel h3 {
|
||||
font-size: calc(1.325rem + .9vw) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 0 10px rgba(10, 10, 10, 0.2), 3px 3px 3px rgba(10, 10, 10, 0.4);
|
||||
}
|
||||
|
||||
@import "dracula.scss";
|
||||
|
|
|
@ -1,93 +0,0 @@
|
|||
.gallery {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2em;
|
||||
overflow: auto hidden;
|
||||
|
||||
li, li a {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 5px;
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-image {
|
||||
position: relative;
|
||||
|
||||
&:hover img {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.video-embed {
|
||||
min-width: 200px;
|
||||
min-height: 133px;
|
||||
background: #111;
|
||||
position: relative;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
cursor: pointer;
|
||||
|
||||
.fa-play {
|
||||
display: block;
|
||||
font-size: 200%;
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #191919;
|
||||
|
||||
.fa-play {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
color: #555;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-add {
|
||||
display: block !important;
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
background: #444;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 133px !important;
|
||||
font-size: 80px;
|
||||
|
||||
&:hover {
|
||||
background: #555;
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
|
@ -1,8 +1,47 @@
|
|||
.badge-tr {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
color: #ccc !important;;
|
||||
.screenshot_list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2em;
|
||||
|
||||
li, li a {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 5px;
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-add {
|
||||
display: block !important;
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
background: #444;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 133px !important;
|
||||
font-size: 80px;
|
||||
|
||||
&:hover {
|
||||
background: #555;
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
#
|
||||
# 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 logging import Filter
|
||||
|
||||
import flask
|
||||
|
@ -68,32 +67,28 @@ celery = make_celery(app)
|
|||
CELERYBEAT_SCHEDULE = {
|
||||
'topic_list_import': {
|
||||
'task': 'app.tasks.forumtasks.importTopicList',
|
||||
'schedule': crontab(minute=1, hour=1), # 0101
|
||||
'schedule': crontab(minute=1, hour=1),
|
||||
},
|
||||
'package_score_update': {
|
||||
'task': 'app.tasks.pkgtasks.updatePackageScores',
|
||||
'schedule': crontab(minute=10, hour=1), # 0110
|
||||
'schedule': crontab(minute=10, hour=1),
|
||||
},
|
||||
'check_for_updates': {
|
||||
'task': 'app.tasks.importtasks.check_for_updates',
|
||||
'schedule': crontab(minute=10, hour=1), # 0110
|
||||
'schedule': crontab(minute=10, hour=1),
|
||||
},
|
||||
'send_pending_notifications': {
|
||||
'task': 'app.tasks.emails.send_pending_notifications',
|
||||
'schedule': crontab(minute='*/5'), # every 5 minutes
|
||||
'schedule': crontab(minute='*/5'),
|
||||
},
|
||||
'send_notification_digests': {
|
||||
'task': 'app.tasks.emails.send_pending_digests',
|
||||
'schedule': crontab(minute=0, hour=14), # 1400
|
||||
},
|
||||
'delete_inactive_users': {
|
||||
'task': 'app.tasks.usertasks.delete_inactive_users',
|
||||
'schedule': crontab(minute=15), # every hour at quarter past
|
||||
},
|
||||
'schedule': crontab(minute=0, hour=14),
|
||||
}
|
||||
}
|
||||
celery.conf.beat_schedule = CELERYBEAT_SCHEDULE
|
||||
|
||||
from . import importtasks, forumtasks, emails, pkgtasks, usertasks
|
||||
from . import importtasks, forumtasks, emails, pkgtasks, celery
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
|
|
|
@ -15,8 +15,7 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import render_template, escape
|
||||
from flask_babel import force_locale, gettext
|
||||
from flask import render_template
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from app.models import Notification, db, EmailSubscription, User
|
||||
|
@ -37,121 +36,112 @@ def get_email_subscription(email):
|
|||
|
||||
|
||||
@celery.task()
|
||||
def send_verify_email(email, token, locale):
|
||||
def send_verify_email(email, token):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message("Confirm email address", recipients=[email])
|
||||
msg = Message("Confirm email address", recipients=[email])
|
||||
|
||||
msg.body = """
|
||||
This email has been sent to you because someone (hopefully you)
|
||||
has entered your email address as a user's email.
|
||||
|
||||
If it wasn't you, then just delete this email.
|
||||
|
||||
If this was you, then please click this link to confirm the address:
|
||||
|
||||
{}
|
||||
""".format(abs_url_for('users.verify_email', token=token))
|
||||
msg.body = """
|
||||
This email has been sent to you because someone (hopefully you)
|
||||
has entered your email address as a user's email.
|
||||
|
||||
msg.html = render_template("emails/verify.html", token=token, sub=sub)
|
||||
mail.send(msg)
|
||||
If it wasn't you, then just delete this email.
|
||||
|
||||
If this was you, then please click this link to confirm the address:
|
||||
|
||||
{}
|
||||
""".format(abs_url_for('users.verify_email', token=token))
|
||||
|
||||
msg.html = render_template("emails/verify.html", token=token, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_unsubscribe_verify(email, locale):
|
||||
def send_unsubscribe_verify(email):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message("Confirm unsubscribe", recipients=[email])
|
||||
msg = Message("Confirm unsubscribe", recipients=[email])
|
||||
|
||||
msg.body = """
|
||||
We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
|
||||
|
||||
Click this link to blacklist email: {}
|
||||
""".format(abs_url_for('users.unsubscribe', token=sub.token))
|
||||
msg.body = """
|
||||
We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
|
||||
|
||||
Click this link to blacklist email: {}
|
||||
""".format(abs_url_for('users.unsubscribe', token=sub.token))
|
||||
|
||||
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
|
||||
mail.send(msg)
|
||||
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task(rate_limit="25/m")
|
||||
def send_email_with_reason(email: str, locale: str, subject: str, text: str, html: str, reason: str):
|
||||
@celery.task()
|
||||
def send_email_with_reason(email, subject, text, html, reason):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
with force_locale(locale or "en"):
|
||||
from flask_mail import Message
|
||||
msg = Message(subject, recipients=[email])
|
||||
from flask_mail import Message
|
||||
msg = Message(subject, recipients=[email])
|
||||
|
||||
msg.body = text
|
||||
html = html or f"<pre>{escape(text)}</pre>"
|
||||
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
|
||||
mail.send(msg)
|
||||
msg.body = text
|
||||
html = html or text
|
||||
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task(rate_limit="25/m")
|
||||
def send_user_email(email: str, locale: str, subject: str, text: str, html=None):
|
||||
with force_locale(locale or "en"):
|
||||
return send_email_with_reason(email, locale, subject, text, html,
|
||||
gettext("You are receiving this email because you are a registered user of ContentDB."))
|
||||
@celery.task()
|
||||
def send_user_email(email: str, subject: str, text: str, html=None):
|
||||
return send_email_with_reason(email, subject, text, html,
|
||||
"You are receiving this email because you are a registered user of ContentDB.")
|
||||
|
||||
|
||||
@celery.task(rate_limit="25/m")
|
||||
def send_anon_email(email: str, locale: str, subject: str, text: str, html=None):
|
||||
with force_locale(locale or "en"):
|
||||
return send_email_with_reason(email, locale, subject, text, html,
|
||||
gettext("You are receiving this email because someone (hopefully you) entered your email address as a user's email."))
|
||||
@celery.task()
|
||||
def send_anon_email(email: str, subject: str, text: str, html=None):
|
||||
return send_email_with_reason(email, subject, text, html,
|
||||
"You are receiving this email because someone (hopefully you) entered your email address as a user's email.")
|
||||
|
||||
|
||||
def send_single_email(notification, locale):
|
||||
def send_single_email(notification):
|
||||
sub = get_email_subscription(notification.user.email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message(notification.title, recipients=[notification.user.email])
|
||||
msg = Message(notification.title, recipients=[notification.user.email])
|
||||
|
||||
msg.body = """
|
||||
New notification: {}
|
||||
|
||||
View: {}
|
||||
|
||||
Manage email settings: {}
|
||||
Unsubscribe: {}
|
||||
""".format(notification.title, abs_url(notification.url),
|
||||
abs_url_for("users.email_notifications", username=notification.user.username),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
msg.body = """
|
||||
New notification: {}
|
||||
|
||||
View: {}
|
||||
|
||||
Manage email settings: {}
|
||||
Unsubscribe: {}
|
||||
""".format(notification.title, abs_url(notification.url),
|
||||
abs_url_for("users.email_notifications", username=notification.user.username),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
|
||||
msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
|
||||
mail.send(msg)
|
||||
msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
def send_notification_digest(notifications: [Notification], locale):
|
||||
def send_notification_digest(notifications: [Notification]):
|
||||
user = notifications[0].user
|
||||
|
||||
sub = get_email_subscription(user.email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message(gettext("%(num)d new notifications", num=len(notifications)), recipients=[user.email])
|
||||
msg = Message("{} new notifications".format(len(notifications)), recipients=[user.email])
|
||||
|
||||
msg.body = "".join(["<{}> {}\n{}: {}\n\n".format(notification.causer.display_name, notification.title, gettext("View"), abs_url(notification.url)) for notification in notifications])
|
||||
msg.body = "".join(["<{}> {}\nView: {}\n\n".format(notification.causer.display_name, notification.title, abs_url(notification.url)) for notification in notifications])
|
||||
|
||||
msg.body += "{}: {}\n{}: {}".format(
|
||||
gettext("Manage email settings"),
|
||||
abs_url_for("users.email_notifications", username=user.username),
|
||||
gettext("Unsubscribe"),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
msg.body += "Manage email settings: {}\nUnsubscribe: {}".format(
|
||||
abs_url_for("users.email_notifications", username=user.username),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
|
||||
msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub)
|
||||
mail.send(msg)
|
||||
msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task()
|
||||
|
@ -164,7 +154,7 @@ def send_pending_digests():
|
|||
notification.emailed = True
|
||||
|
||||
if len(to_send) > 0:
|
||||
send_notification_digest(to_send, user.locale or "en")
|
||||
send_notification_digest(to_send)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
@ -184,6 +174,6 @@ def send_pending_notifications():
|
|||
db.session.commit()
|
||||
|
||||
if len(to_send) > 1:
|
||||
send_notification_digest(to_send, user.locale or "en")
|
||||
send_notification_digest(to_send)
|
||||
elif len(to_send) > 0:
|
||||
send_single_email(to_send[0], user.locale or "en")
|
||||
send_single_email(to_send[0])
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
import json, re, sys
|
||||
from app.models import *
|
||||
from app.tasks import celery
|
||||
from app.utils import is_username_valid
|
||||
from app.utils.phpbbparser import getProfile, getTopicsFromForum
|
||||
import urllib.request
|
||||
|
||||
|
@ -45,7 +44,7 @@ def checkForumAccount(username, forceNoSave=False):
|
|||
# Get github username
|
||||
github_username = profile.get("github")
|
||||
if github_username is not None and github_username.strip() != "":
|
||||
print("Updated GitHub username for " + user.display_name + " to " + github_username)
|
||||
print("Updated github username for " + user.display_name + " to " + github_username)
|
||||
user.github_username = github_username
|
||||
needsSaving = True
|
||||
|
||||
|
@ -138,9 +137,6 @@ def importTopicList():
|
|||
if user:
|
||||
return user
|
||||
|
||||
if not is_username_valid(username):
|
||||
return None
|
||||
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user is None:
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
#
|
||||
# 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/>.
|
||||
|
||||
import json
|
||||
import os, shutil, gitdb
|
||||
from zipfile import ZipFile
|
||||
|
@ -23,13 +22,11 @@ from kombu import uuid
|
|||
|
||||
from app.models import *
|
||||
from app.tasks import celery, TaskError
|
||||
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog
|
||||
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_system_user
|
||||
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir
|
||||
from .minetestcheck import build_tree, MinetestCheckError, ContentType
|
||||
from ..logic.LogicError import LogicError
|
||||
from ..logic.game_support import GameSupportResolver
|
||||
from ..logic.packages import do_edit_package, ALIASES
|
||||
from ..utils.image import get_image_size
|
||||
|
||||
|
||||
@celery.task()
|
||||
|
@ -73,14 +70,11 @@ def getMeta(urlstr, author):
|
|||
return result
|
||||
|
||||
|
||||
def postReleaseCheckUpdate(self, release: PackageRelease, path):
|
||||
def postReleaseCheckUpdate(self, release, path):
|
||||
try:
|
||||
tree = build_tree(path, expected_type=ContentType[release.package.type.name],
|
||||
author=release.package.author.username, name=release.package.name)
|
||||
|
||||
if tree.name is not None and release.package.name != tree.name and tree.type == ContentType.MOD:
|
||||
raise MinetestCheckError(f"Expected {tree.relative} to have technical name {release.package.name}, instead has name {tree.name}")
|
||||
|
||||
cache = {}
|
||||
def getMetaPackages(names):
|
||||
return [ MetaPackage.GetOrCreate(x, cache) for x in names ]
|
||||
|
@ -103,11 +97,6 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
|
|||
depends.discard(mod)
|
||||
optional_depends.discard(mod)
|
||||
|
||||
# Raise error on unresolved game dependencies
|
||||
if package.type == PackageType.GAME and len(depends) > 0:
|
||||
deps = ", ".join(depends)
|
||||
raise MinetestCheckError("Game has unresolved hard dependencies: " + deps)
|
||||
|
||||
# Add dependencies
|
||||
for meta in getMetaPackages(depends):
|
||||
db.session.add(Dependency(package, meta=meta, optional=False))
|
||||
|
@ -115,11 +104,6 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
|
|||
for meta in getMetaPackages(optional_depends):
|
||||
db.session.add(Dependency(package, meta=meta, optional=True))
|
||||
|
||||
# Update game supports
|
||||
if package.type == PackageType.MOD:
|
||||
resolver = GameSupportResolver()
|
||||
resolver.update(package)
|
||||
|
||||
# Update min/max
|
||||
if tree.meta.get("min_minetest_version"):
|
||||
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
|
||||
|
@ -130,7 +114,7 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
|
|||
try:
|
||||
with open(os.path.join(tree.baseDir, ".cdb.json"), "r") as f:
|
||||
data = json.loads(f.read())
|
||||
do_edit_package(package.author, package, False, False, data, "Post release hook")
|
||||
do_edit_package(package.author, package, False, data, "Post release hook")
|
||||
except LogicError as e:
|
||||
raise TaskError(e.message)
|
||||
except IOError:
|
||||
|
@ -141,9 +125,6 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
|
|||
except MinetestCheckError as err:
|
||||
db.session.rollback()
|
||||
|
||||
msg = f"{err}\n\nTask ID: {self.request.id}\n\nRelease: [View Release]({release.getEditURL()})"
|
||||
post_bot_message(release.package, f"Release {release.title} validation failed", msg)
|
||||
|
||||
if "Fails validation" not in release.title:
|
||||
release.title += " (Fails validation)"
|
||||
|
||||
|
@ -221,10 +202,6 @@ def importRepoScreenshot(id):
|
|||
ss.package = package
|
||||
ss.title = "screenshot.png"
|
||||
ss.url = "/uploads/" + filename
|
||||
ss.width, ss.height = get_image_size(destPath)
|
||||
if ss.is_too_small():
|
||||
return None
|
||||
|
||||
db.session.add(ss)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -277,7 +254,7 @@ def check_update_config_impl(package):
|
|||
db.session.add(rel)
|
||||
|
||||
msg = "Created release {} (Git Update Detection)".format(rel.title)
|
||||
addSystemAuditLog(AuditSeverity.NORMAL, msg, package.getURL("packages.view"), package)
|
||||
addSystemAuditLog(AuditSeverity.NORMAL, msg, package.getDetailsURL(), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
@ -335,7 +312,7 @@ def check_update_config(self, package_id):
|
|||
.strip()
|
||||
|
||||
msg = "Error: {}.\n\nTask ID: {}\n\n[Change update configuration]({})" \
|
||||
.format(err, self.request.id, package.getURL("packages.update_config"))
|
||||
.format(err, self.request.id, package.getUpdateConfigURL())
|
||||
|
||||
post_bot_message(package, "Failed to check git repository", msg)
|
||||
|
||||
|
|
|
@ -23,8 +23,8 @@ def detect_type(path):
|
|||
elif os.path.isfile(path + "/modpack.txt") or \
|
||||
os.path.isfile(path + "/modpack.conf"):
|
||||
return ContentType.MODPACK
|
||||
# elif os.path.isdir(path + "/mods"):
|
||||
# return ContentType.GAME
|
||||
elif os.path.isdir(path + "/mods"):
|
||||
return ContentType.GAME
|
||||
elif os.path.isfile(path + "/texture_pack.conf"):
|
||||
return ContentType.TXP
|
||||
else:
|
||||
|
@ -140,7 +140,7 @@ class PackageTreeNode:
|
|||
|
||||
|
||||
def checkDependencies(deps):
|
||||
for dep in deps:
|
||||
for dep in result["depends"]:
|
||||
if not basenamePattern.match(dep):
|
||||
if " " in dep:
|
||||
raise MinetestCheckError("Invalid dependency name '{}' for mod at {}, did you forget a comma?" \
|
||||
|
@ -155,7 +155,7 @@ class PackageTreeNode:
|
|||
checkDependencies(result["optional_depends"])
|
||||
|
||||
# Fix games using "name" as "title"
|
||||
if self.type == ContentType.GAME and "name" in result:
|
||||
if self.type == ContentType.GAME:
|
||||
result["title"] = result["name"]
|
||||
del result["name"]
|
||||
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
# ContentDB
|
||||
# Copyright (C) 2021 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/>.
|
||||
|
||||
|
||||
import datetime
|
||||
from app.models import User, db, UserRank
|
||||
from app.tasks import celery
|
||||
|
||||
|
||||
@celery.task()
|
||||
def delete_inactive_users():
|
||||
threshold = datetime.datetime.now() - datetime.timedelta(hours=5)
|
||||
|
||||
users = User.query.filter(User.is_active==False, User.packages==None, User.forum_topics==None, User.created_at<=threshold, User.rank==UserRank.NOT_JOINED).all()
|
||||
for user in users:
|
||||
db.session.delete(user)
|
||||
|
||||
db.session.commit()
|
|
@ -1,40 +0,0 @@
|
|||
# ContentDB
|
||||
# Copyright (C) 2021 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 typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app import app
|
||||
from app.models import User
|
||||
from app.tasks import celery
|
||||
|
||||
@celery.task()
|
||||
def post_discord_webhook(username: Optional[str], content: str, is_queue: bool):
|
||||
discord_url = app.config.get("DISCORD_WEBHOOK_QUEUE" if is_queue else "DISCORD_WEBHOOK_FEED")
|
||||
if discord_url is None:
|
||||
return
|
||||
|
||||
json = {
|
||||
"content": content,
|
||||
}
|
||||
|
||||
if username:
|
||||
json["username"] = username
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
json["avatar_url"] = user.getProfilePicURL().replace("/./", "/")
|
||||
|
||||
requests.post(discord_url, json=json)
|
|
@ -1,12 +1,12 @@
|
|||
from . import app, utils
|
||||
from .models import Permission, Package, PackageState, PackageRelease
|
||||
from .utils import abs_url_for, url_set_query, url_set_anchor, url_current
|
||||
from .utils import abs_url_for, url_set_query
|
||||
from flask_login import current_user
|
||||
from flask_babel import format_timedelta, gettext
|
||||
from urllib.parse import urlparse
|
||||
from datetime import datetime as dt
|
||||
|
||||
from app.markdown import get_headings
|
||||
from .utils.markdown import get_headings
|
||||
|
||||
|
||||
@app.context_processor
|
||||
|
@ -16,8 +16,9 @@ def inject_debug():
|
|||
@app.context_processor
|
||||
def inject_functions():
|
||||
check_global_perm = Permission.checkPerm
|
||||
return dict(abs_url_for=abs_url_for, url_set_query=url_set_query, url_set_anchor=url_set_anchor,
|
||||
check_global_perm=check_global_perm, get_headings=get_headings, url_current=url_current)
|
||||
return dict(abs_url_for=abs_url_for, url_set_query=url_set_query,
|
||||
check_global_perm=check_global_perm,
|
||||
get_headings=get_headings)
|
||||
|
||||
@app.context_processor
|
||||
def inject_todo():
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("Page not found") }}
|
||||
Page not found
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ self.title() }}</h1>
|
||||
<h1>Page not found</h1>
|
||||
<p>
|
||||
{{ _("That page could not be found. The link may be broken, the page may have been deleted, or you may not have access to it.") }}
|
||||
That page could not be found. The link may be broken, the page may have been deleted,
|
||||
or you may not have access to it.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
|
|
@ -12,13 +12,12 @@
|
|||
<a class="btn btn-primary float-right" href="{{ url_for('admin.create_edit_license') }}">New License</a>
|
||||
<a class="btn btn-secondary mb-4" href="{{ url_for('admin.license_list') }}">Back to list</a>
|
||||
|
||||
{% from "macros/forms.html" import render_field, render_checkbox_field, render_submit_field %}
|
||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{{ render_field(form.name) }}
|
||||
{{ render_checkbox_field(form.is_foss) }}
|
||||
{{ render_field(form.url) }}
|
||||
{{ render_field(form.is_foss) }}
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -27,11 +27,18 @@
|
|||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="row px-3">
|
||||
<select name="action" class="custom-select col">
|
||||
{% for id, action in actions.items() %}
|
||||
<option value="{{ id }}" {% if loop.first %}selected{% endif %}>
|
||||
{{ action["title"] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<option value="cleanuploads" selected>Delete unreachable uploads</option>
|
||||
<option value="delmetapackages">Delete unused meta packages</option>
|
||||
<option value="delstuckreleases">Delete stuck releases</option>
|
||||
<option value="reimportpackages">Reimport meta</option>
|
||||
<option value="recalcscores">Recalculate package scores</option>
|
||||
<option value="div">------</option>
|
||||
<option value="checkreleases">Validate all Zip releases</option>
|
||||
<option value="importmodlist">Import forum topics</option>
|
||||
<option value="checkusers">Check forum users</option>
|
||||
<option value="importscreenshots">Import screenshots from VCS</option>
|
||||
<option value="addupdateconfig">Add update configs</option>
|
||||
<option value="runupdateconfig">Run update configs</option>
|
||||
</select>
|
||||
<input type="submit" value="Perform" class="col-sm-auto btn btn-primary ml-2" />
|
||||
</div>
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Restore
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card w-50">
|
||||
<h2 class="card-header">Restore Package</h2>
|
||||
|
||||
<form method="post" action="" class="card-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="">
|
||||
<select name="package" class="custom-select px-3" required>
|
||||
<option disabled selected>-- please select --</option>
|
||||
{% for p in deleted_packages %}
|
||||
<option value="{{ p.id }}">
|
||||
{{ p.author.username }} / {{ p.name }} | id={{ p.id }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="mt-3">
|
||||
<input type="submit" name="submit" value="To Draft" class="col-sm-auto btn btn-warning" />
|
||||
<input type="submit" name="submit" value="To Changes Needed" class="col-sm-auto btn btn-danger ml-2" />
|
||||
<input type="submit" name="submit" value="To Ready for Review" class="col-sm-auto btn btn-success ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -12,7 +12,7 @@
|
|||
<a class="btn btn-primary float-right" href="{{ url_for('admin.create_edit_tag') }}">New Tag</a>
|
||||
<a class="btn btn-secondary mb-4" href="{{ url_for('admin.tag_list') }}">Back to list</a>
|
||||
|
||||
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
|
||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
|
@ -21,10 +21,6 @@
|
|||
{% if tag %}
|
||||
{{ render_field(form.name) }}
|
||||
{% endif %}
|
||||
<div class="form-group my-5">
|
||||
{{ render_checkbox_field(form.is_protected) }}
|
||||
<small class="form-text text-muted">Whether non-Editors can add this tag to packages</small>
|
||||
</div>
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
{% if token %}
|
||||
<form class="float-right" method="POST" action="{{ url_for('api.delete_token', username=token.owner.username, id=token.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input class="btn btn-danger" type="submit" value="{{ _('Delete') }}">
|
||||
<input class="btn btn-danger" type="submit" value="Delete">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
|
@ -30,14 +30,15 @@
|
|||
<div class="card-header">{{ _("Access Token") }}</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
{{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }}
|
||||
For security reasons, access tokens will only be shown once.
|
||||
Reset the token if it is lost.
|
||||
</p>
|
||||
{% if access_token %}
|
||||
<input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control">
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input class="btn btn-primary" type="submit" value="{{ _('Reset') }}">
|
||||
<input class="btn btn-primary" type="submit" value="Reset">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block pane %}
|
||||
<a class="btn btn-primary float-right" href="{{ url_for('api.create_edit_token', username=user.username) }}">{{ _("Create") }}</a>
|
||||
<a class="btn btn-secondary mr-2 float-right" href="/help/api/">{{ _("API Documentation") }}</a>
|
||||
<a class="btn btn-primary float-right" href="{{ url_for('api.create_edit_token', username=user.username) }}">Create</a>
|
||||
<a class="btn btn-secondary mr-2 float-right" href="/help/api/">API Documentation</a>
|
||||
<h2 class="mt-0">{{ _("API Tokens") }}</h2>
|
||||
|
||||
<div class="list-group">
|
||||
|
@ -16,7 +16,7 @@
|
|||
</a>
|
||||
{% else %}
|
||||
<span class="list-group-item">
|
||||
<i>{{ _("No tokens created") }}</i>
|
||||
<i>No tokens created</i>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title>
|
||||
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=34">
|
||||
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=25">
|
||||
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
|
||||
<link rel="shortcut icon" href="/favicon-16.png" sizes="16x16">
|
||||
<link rel="icon" href="/favicon-128.png" sizes="128x128">
|
||||
|
@ -23,31 +23,34 @@
|
|||
|
||||
<div class="collapse navbar-collapse" id="navbarColor01">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('packages.list_all', type='mod') }}">{{ _("Mods") }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('packages.list_all', type='game') }}">{{ _("Games") }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('packages.list_all', type='txp') }}">{{ _("Texture Packs") }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('packages.list_all', random=1, lucky=1) }}">{{ _("Random") }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('flatpage', path='help') }}">{{ _("Help") }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('threads.list_all') }}">{{ _("Threads") }}</a>
|
||||
</li>
|
||||
{% for item in current_menu.children recursive %}
|
||||
{% if item.visible %}
|
||||
<li class="nav-item {% if item.children %} dropdown{% endif %}">
|
||||
<a class="nav-link" href="{{ item.url }}"
|
||||
{% if item.children %}
|
||||
class="dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
{% endif %}>
|
||||
{{ item.text }}
|
||||
{% if item.children %}
|
||||
<span class="caret"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if item.children %}
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
{{ loop(item.children) }}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form class="form-inline my-2 my-lg-0" method="GET" action="/packages/">
|
||||
{% if type %}<input type="hidden" name="type" value="{{ type }}" />{% endif %}
|
||||
<input class="form-control" name="q" type="text"
|
||||
placeholder="{% if query_hint %}{{ _('Search %(type)s', type=query_hint | lower) }}{% else %}{{ _('Search all packages') }}{% endif %}"
|
||||
value="{{ query or ''}}">
|
||||
<input class="btn btn-secondary my-2 my-sm-0 mr-sm-2" type="submit" value="{{ _('Search') }}" />
|
||||
<input class="form-control mr-sm-2" name="q" type="text" placeholder="Search {{ title | lower or 'all packages' }}" value="{{ query or ''}}">
|
||||
<input class="btn btn-secondary my-2 my-sm-0 mr-sm-2" type="submit" value="Search" />
|
||||
<!-- <input class="btn btn-secondary my-2 my-sm-0"
|
||||
data-toggle="tooltip" data-placement="bottom"
|
||||
title="Go to the first found result for this query."
|
||||
|
@ -62,7 +65,7 @@
|
|||
title="{{ _('Work Queue') }}">
|
||||
{% if todo_list_count > 0 %}
|
||||
<i class="fas fa-inbox"></i>
|
||||
<span class="badge badge-pill badge-notify">{{ todo_list_count }}</span>
|
||||
<span class="badge badge-pill badge-notify" style="font-size:10px;">{{ todo_list_count }}</span>
|
||||
{% else %}
|
||||
<i class="fas fa-inbox" ></i>
|
||||
{% endif %}
|
||||
|
@ -84,16 +87,7 @@
|
|||
title="{{ _('Notifications') }}">
|
||||
{% if current_user.notifications %}
|
||||
<i class="fas fa-bell"></i>
|
||||
{% set num_notifs = current_user.notifications | length %}
|
||||
{% if num_notifs > 60 %}
|
||||
<span class="badge badge-pill badge-notify badge-emoji">
|
||||
😢
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pill badge-notify">
|
||||
{{ num_notifs }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge badge-pill badge-notify" style="font-size:10px;">{{ current_user.notifications | length }}</span>
|
||||
{% else %}
|
||||
<i class="fas fa-bell" ></i>
|
||||
{% endif %}
|
||||
|
@ -106,57 +100,39 @@
|
|||
<i class="fas fa-plus"></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
role="button"
|
||||
aria-expanded="false">
|
||||
{{ current_user.display_name }}
|
||||
<span class="caret"></span>
|
||||
</a>
|
||||
data-toggle="dropdown"
|
||||
role="button"
|
||||
aria-expanded="false">{{ current_user.display_name }}
|
||||
<span class="caret"></span></a>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-right" role="menu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('users.profile', username=current_user.username) }}">
|
||||
{{ _("Profile") }}
|
||||
</a>
|
||||
<a class="nav-link" href="{{ url_for('users.profile', username=current_user.username) }}">Profile</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('todo.view_user', username=current_user.username) }}">
|
||||
{{ _("To do list") }}
|
||||
</a>
|
||||
<a class="nav-link" href="{{ url_for('todo.view_user', username=current_user.username) }}">To do list</a>
|
||||
</li>
|
||||
{% if current_user.rank.atLeast(current_user.rank.EDITOR) or check_global_perm(current_user, "CREATE_TAG") %}
|
||||
<li class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
{% if current_user.rank.atLeast(current_user.rank.MODERATOR) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.audit') }}">
|
||||
{{ _("Audit Log") }}
|
||||
</a>
|
||||
</li>
|
||||
<div class="dropdown-divider"></div>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.audit') }}">{{ _("Audit Log") }}</a></li>
|
||||
{% if current_user.rank == current_user.rank.ADMIN %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.admin_page') }}">{{ _("Admin") }}</a></li>
|
||||
{% else %}
|
||||
{% if check_global_perm(current_user, "EDIT_TAGS") %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.tag_list') }}">{{ _("Tag Editor") }}</a></li>
|
||||
{% elif check_global_perm(current_user, "CREATE_TAG") %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.create_edit_tag') }}">{{ _("Create Tag") }}</a></li>
|
||||
{% endif %}
|
||||
{% if current_user.rank == current_user.rank.MODERATOR %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.license_list') }}">{{ _("License Editor") }}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if current_user.rank.atLeast(current_user.rank.EDITOR) %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.restore') }}">{{ _("Restore Package") }}</a></li>
|
||||
{% endif %}
|
||||
{% if check_global_perm(current_user, "EDIT_TAGS") %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.tag_list') }}">{{ _("Tag Editor") }}</a></li>
|
||||
{% elif check_global_perm(current_user, "CREATE_TAG") %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.create_edit_tag') }}">{{ _("Create Tag") }}</a></li>
|
||||
{% endif %}
|
||||
<li class="dropdown-divider"></li>
|
||||
<div class="dropdown-divider"></div>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('users.profile_edit', username=current_user.username) }}">
|
||||
{{ _("Settings") }}
|
||||
</a>
|
||||
<a class="nav-link" href="{{ url_for('users.profile_edit', username=current_user.username) }}">Settings</a>
|
||||
</li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('users.logout') }}">{{ _("Sign out") }}</a></li>
|
||||
</ul>
|
||||
|
@ -164,36 +140,6 @@
|
|||
{% else %}
|
||||
<li><a class="nav-link" href="{{ url_for('users.login') }}">{{ _("Sign in") }}</a></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
role="button"
|
||||
aria-expanded="false">
|
||||
<i class="fas fa-language"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-right" role="menu">
|
||||
{% for locale, locale_name in config["LANGUAGES"].items() %}
|
||||
<li class="nav-item">
|
||||
<form method="POST" action="{{ url_for('set_locale') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="hidden" name="locale" value="{{ locale }}" />
|
||||
<input type="hidden" name="r" value="{{ url_set_query() }}" />
|
||||
<input type="submit" class="btn btn-link nav-link" value="{{ locale_name }} ({{ locale }})">
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="dropdown-divider"></li>
|
||||
<li class="nav-item ">
|
||||
<a class="nav-link" href="https://hosted.weblate.org/projects/minetest/contentdb/">
|
||||
<small>
|
||||
{{ _("Help translate ContentDB") }}
|
||||
</small>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -226,7 +172,7 @@
|
|||
|
||||
<footer class="my-5">
|
||||
<p class="pt-5 mb-1">
|
||||
ContentDB © 2018-22 to <a href="https://rubenwardy.com/">rubenwardy</a>
|
||||
ContentDB © 2018-21 to <a href="https://rubenwardy.com/">rubenwardy</a>
|
||||
</p>
|
||||
|
||||
<ul class="list-inline my-1">
|
||||
|
@ -234,9 +180,7 @@
|
|||
<li class="list-inline-item"><a href="{{ url_for('flatpage', path='policy_and_guidance') }}">{{ _("Policy and Guidance") }}</a></li>
|
||||
<li class="list-inline-item"><a href="{{ url_for('flatpage', path='help/api') }}">{{ _("API") }}</a></li>
|
||||
<li class="list-inline-item"><a href="{{ url_for('flatpage', path='privacy_policy') }}">{{ _("Privacy Policy") }}</a></li>
|
||||
{% if request.endpoint != "flatpage" %}
|
||||
<li class="list-inline-item"><a href="{{ url_for('report.report', url=url_current()) }}">{{ _("Report") }}</a></li>
|
||||
{% endif %}
|
||||
<li class="list-inline-item"><a href="{{ url_for('flatpage', path='help/reporting') }}">{{ _("Report / DMCA") }}</a></li>
|
||||
<li class="list-inline-item"><a href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb">{{ _("Stats / Monitoring") }}</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="https://github.com/minetest/contentdb">{{ _("Source Code") }}</a></li>
|
||||
|
|
|
@ -16,15 +16,15 @@
|
|||
|
||||
<p>
|
||||
<a class="btn" href="{{ notification.url | abs_url }}">
|
||||
{{ _("View Notification") }}
|
||||
View Notification
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
{{ _("You are receiving this email because you are a registered user of ContentDB, and have email notifications enabled.") }}
|
||||
<br>
|
||||
You are receiving this email because you are a registered user of ContentDB,
|
||||
and have email notifications enabled. <br>
|
||||
|
||||
<a href="{{ abs_url_for('users.email_notifications', username=notification.user.username) }}">
|
||||
{{ _("Manage your preferences") }}
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% for title, group in notifications | selectattr("package") | groupby("package.title") %}
|
||||
{% for type, group in notifications | groupby("package.title") %}
|
||||
<h2>
|
||||
{{ title }}
|
||||
{{ type or _("Other Notifications") }}
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
|
@ -17,34 +17,17 @@
|
|||
</ul>
|
||||
{% endfor %}
|
||||
|
||||
{% set other_notifications = notifications | selectattr("package", "none") %}
|
||||
|
||||
{% if other_notifications %}
|
||||
<h2>
|
||||
{{ _("Other Notifications") }}
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
{% for notification in other_notifications %}
|
||||
<li>
|
||||
<a href="{{ notification.url | abs_url }}">{{ notification.title }}</a> -
|
||||
{{ _("from %(username)s.", username=notification.causer.username) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top: 3em;">
|
||||
<a class="btn" href="{{ abs_url_for('notifications.list_all') }}">
|
||||
{{ _("View Notifications") }}
|
||||
View Notifications
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
{{ _("You are receiving this email because you are a registered user of ContentDB, and have email notifications enabled.") }}
|
||||
<br>
|
||||
You are receiving this email because you are a registered user of ContentDB,
|
||||
and have email notifications enabled. <br>
|
||||
|
||||
<a href="{{ abs_url_for('users.email_notifications', username=user.username) }}">
|
||||
{{ _("Manage your preferences") }}
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
<p>
|
||||
{{ _("We were unable to perform the password reset as we could not find an account associated with this email.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("This may be because you used another email with your account, or because you never confirmed your email.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("You can use GitHub to log in if it is associated with your account.") }}
|
||||
{{ _("Otherwise, you may need to contact rubenwardy for help.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("If you weren't expecting to receive this email, then you can safely ignore it.") }}
|
||||
</p>
|
|
@ -1,34 +1,33 @@
|
|||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2 style="margin-top: 0;">{{ _("Hello!") }}</h2>
|
||||
<h2 style="margin-top: 0;">Hello!</h2>
|
||||
|
||||
<p>
|
||||
{{ _("This email has been sent to you because someone (hopefully you) has entered your email address as a user's email.") }}
|
||||
This email has been sent to you because someone (hopefully you)
|
||||
has entered your email address as a user's email.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ _("If it wasn't you, then just delete this email.") }}
|
||||
If it wasn't you, then just delete this email.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ _("If this was you, then please click this link to confirm the address:") }}
|
||||
If this was you, then please click this link to confirm the address:
|
||||
</p>
|
||||
|
||||
<a class="btn" href="{{ abs_url_for('users.verify_email', token=token) }}">
|
||||
{{ _("Confirm Email Address") }}
|
||||
Confirm Email Address
|
||||
</a>
|
||||
|
||||
<p style="font-size: 80%;">
|
||||
{{ _("Or paste this into your browser:") }}
|
||||
<code>{{ abs_url_for('users.verify_email', token=token) }}</code>
|
||||
Or paste this into your browser: <code>{{ abs_url_for('users.verify_email', token=token) }}</code>
|
||||
<p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
{{ _("You are receiving this email because someone (hopefully you) entered your email address as a user's email.") }}
|
||||
<br>
|
||||
You are receiving this email because someone (hopefully you) entered your email address as a user's email. <br>
|
||||
<a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
|
||||
{{ _("Unsubscribe") }}
|
||||
</a>
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2 style="margin-top: 0;">
|
||||
{{ _("Hello!") }}
|
||||
</h2>
|
||||
<h2 style="margin-top: 0;">Hello!</h2>
|
||||
|
||||
<p>
|
||||
{{ _("We're sorry to see you go. You just need to do one more thing before your email is blacklisted.") }}
|
||||
We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
|
||||
</p>
|
||||
|
||||
<a class="btn" href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
|
||||
{{ _("Unsubscribe") }}
|
||||
Unsubscribe
|
||||
</a>
|
||||
|
||||
<p style="font-size: 80%;">
|
||||
{{ _("Or paste this into your browser:") }} <code>{{ abs_url_for('users.unsubscribe', token=sub.token) }}</code>
|
||||
Or paste this into your browser: <code>{{ abs_url_for('users.unsubscribe', token=sub.token) }}</code>
|
||||
<p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
{{ _("You are receiving this email because someone (hopefully you) entered your email address in the unsubscribe form.") }}
|
||||
You are receiving this email because someone (hopefully you) entered your email address in the unsubscribe form.
|
||||
{% endblock %}
|
||||
|
|
|
@ -21,81 +21,7 @@
|
|||
|
||||
{% block content %}
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
<div id="featuredCarousel" class="carousel slide my-0" data-ride="carousel" data-interval="7500">
|
||||
<ol class="carousel-indicators">
|
||||
{% for package in featured %}
|
||||
<li data-target="#featuredCarousel" data-slide-to="{{ loop.index - 1 }}" {% if loop.index == 1 %}class="active"{% endif %}></li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<div class="carousel-inner">
|
||||
{% for package in featured %}
|
||||
{% set cover_image = package.cover_image.url or package.getMainScreenshotURL() %}
|
||||
{% set tags = package.tags | sort(attribute="views", reverse=True) %}
|
||||
<div class="carousel-item {% if loop.index == 1 %}active{% endif %}">
|
||||
<a href="{{ package.getURL("packages.view") }}">
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<img class="embed-responsive-item" src="{{ cover_image }}"
|
||||
alt="{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}">
|
||||
</div>
|
||||
<div class="carousel-caption text-shadow">
|
||||
<h3 class="mt-0 mb-3">
|
||||
{% if package.author %}
|
||||
{{ _('<strong>%(title)s</strong> by %(author)s', title=package.title, author=package.author.display_name) }}
|
||||
{% else %}
|
||||
<strong>{{ package.title }}</strong>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p>
|
||||
{{ package.short_desc }}
|
||||
</p>
|
||||
{% if package.author %}
|
||||
<div class="d-none d-md-block">
|
||||
<span class="mr-2">
|
||||
{{ package.type.text }}
|
||||
</span>
|
||||
{% for warning in package.content_warnings %}
|
||||
<span class="badge badge-warning" title="{{ warning.description }}">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
|
||||
{{ warning.title }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% for t in tags[:3] %}
|
||||
{% if t.name != "featured" %}
|
||||
<span class="badge badge-primary" title="{{ t.description or '' }}">
|
||||
{{ t.title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="btn" title="{{ _('Reviews') }}">
|
||||
<i class="fas fa-star-half-alt"></i>
|
||||
<span class="count">
|
||||
+{{ package.reviews | selectattr("recommends") | list | length }}
|
||||
/
|
||||
-{{ package.reviews | rejectattr("recommends") | list | length }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a class="carousel-control-prev" href="#featuredCarousel" role="button" data-slide="prev">
|
||||
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
|
||||
<span class="sr-only">{{ _("Previous") }}</span>
|
||||
</a>
|
||||
<a class="carousel-control-next" href="#featuredCarousel" role="button" data-slide="next">
|
||||
<span class="carousel-control-next-icon" aria-hidden="true"></span>
|
||||
<span class="sr-only">{{ _("Next") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-right mb-5 text-muted" style="opacity: 0.4;">
|
||||
<a href="/help/featured/" class="btn">
|
||||
<i class="fas fa-question-circle mr-1"></i>
|
||||
{{ _("Featured") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<a href="{{ url_for('packages.list_all', sort='approved_at', order='desc') }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
|
@ -150,7 +76,7 @@
|
|||
<a href="{{ url_for('packages.list_all', sort='reviews', order='desc') }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Highest Reviewed") }}</h2>
|
||||
<h2 class="my-3">{{ _("Top Reviewed") }}</h2>
|
||||
{{ render_pkggrid(high_reviewed) }}
|
||||
|
||||
|
||||
|
@ -158,7 +84,7 @@
|
|||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Recent Positive Reviews") }}</h2>
|
||||
{% from "macros/reviews.html" import render_reviews with context %}
|
||||
{% from "macros/reviews.html" import render_reviews %}
|
||||
{{ render_reviews(reviews, current_user, True) }}
|
||||
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
{% for entry in log %}
|
||||
<a class="list-group-item list-group-item-action"
|
||||
{% if entry.description and current_user.rank.atLeast(current_user.rank.MODERATOR) %}
|
||||
href="{{ url_for('admin.audit_view', id_=entry.id) }}">
|
||||
href="{{ url_for('admin.audit_view', id=entry.id) }}">
|
||||
{% else %}
|
||||
href="{{ entry.url }}">
|
||||
{% endif %}
|
||||
|
@ -29,7 +29,7 @@
|
|||
|
||||
<span class="pl-2">{{ entry.causer.username }}</span>
|
||||
{% else %}
|
||||
<i>{{ _("Deleted User") }}</i>
|
||||
<i>Deleted User</i>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -61,7 +61,7 @@
|
|||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="list-group-item"><i>{{ _("No audit log entries.") }}</i></p>
|
||||
<p class="list-group-item"><i>No audit log entires.</i></p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
</div>
|
||||
{{ field(class_=fieldclass or 'form-control', **kwargs) }}
|
||||
<a class="btn btn-secondary" id="{{ field.name }}-button">
|
||||
{{ _("View") }}
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
@ -104,7 +104,7 @@
|
|||
<label for="{{ field.id }}">{{ label|safe }}</label>
|
||||
{% endif %}
|
||||
<div class="multichoice_selector bulletselector form-control">
|
||||
<input type="text" placeholder="{{ _('Start typing to see suggestions') }}">
|
||||
<input type="text" placeholder="Start typing to see suggestions">
|
||||
<div class="clearboth"></div>
|
||||
</div>
|
||||
<div class="invalid-remaining invalid-feedback"></div>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{% macro render_banners(package, current_user, topic_error, topic_error_lvl, conflicting_modnames) -%}
|
||||
{% macro render_banners(package, current_user, topic_error, topic_error_lvl, similar_topics) -%}
|
||||
|
||||
<div class="row mb-4">
|
||||
<span class="col">
|
||||
{{ _("State") }}: <strong>{{ package.state.value }}</strong>
|
||||
State: <strong>{{ package.state.value }}</strong>
|
||||
</span>
|
||||
|
||||
{% for state in package.getNextStates(current_user) %}
|
||||
|
@ -14,62 +14,51 @@
|
|||
</div>
|
||||
|
||||
{% set level = "warning" %}
|
||||
{% if package.releases.filter_by(task_id=None).count() == 0 %}
|
||||
{% if package.releases.count() == 0 %}
|
||||
{% set message %}
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
|
||||
{% if package.update_config %}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.create_release') }}">
|
||||
{{ _("Create release") }}
|
||||
</a>
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getCreateReleaseURL() }}">Create first release</a>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.setup_releases') }}">
|
||||
{{ _("Set up releases") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if package.releases.count() == 0 %}
|
||||
{{ _("You need to create a release before this package can be approved.") }}
|
||||
{% else %}
|
||||
{{ _("Release is still importing, or has an error.") }}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getSetupReleasesURL() }}">Set up releases</a>
|
||||
{% endif %}
|
||||
{{ _("You need to create a release before this package can be approved.") }}
|
||||
{% else %}
|
||||
{{ _("A release is required before this package can be approved.") }}
|
||||
{% endif %}
|
||||
{% endset %}
|
||||
|
||||
{% elif (package.type == package.type.GAME or package.type == package.type.TXP) and package.screenshots.count() == 0 %}
|
||||
{% set message = _("You need to add at least one screenshot.") %}
|
||||
{% set message = "You need to add at least one screenshot." %}
|
||||
|
||||
{% elif package.getMissingHardDependenciesQuery().count() > 0 %}
|
||||
{% set deps = package.getMissingHardDependencies() | join(", ") %}
|
||||
{% set message = _("The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=deps) %}
|
||||
{% set message = "The following hard dependencies need to be added to ContentDB first: " + deps %}
|
||||
|
||||
{% elif topic_error_lvl == "danger" %}
|
||||
{% elif package.state == package.state.READY_FOR_REVIEW and ("Other" in package.license.name or "Other" in package.media_license.name) %}
|
||||
{% set message = _("Please wait for the license to be added to CDB.") %}
|
||||
{% set message = "Please wait for the license to be added to CDB." %}
|
||||
|
||||
{% else %}
|
||||
{% set level = "info" %}
|
||||
{% set message %}
|
||||
{% if package.screenshots.count() == 0 %}
|
||||
<b>
|
||||
{{ _("You should add at least one screenshot, but this isn't required.") }}
|
||||
</b><br />
|
||||
<b>You should add at least one screenshot, but this isn't required.</b><br />
|
||||
{% endif %}
|
||||
|
||||
{% if package.state == package.state.READY_FOR_REVIEW %}
|
||||
{% if not package.getDownloadRelease() %}
|
||||
{{ _("Please wait for the release to be approved.") }}
|
||||
Please wait for the release to be approved.
|
||||
{% elif package.checkPerm(current_user, "APPROVE_NEW") %}
|
||||
{{ _("You can now approve this package if you're ready.") }}
|
||||
You can now approve this package if you're ready.
|
||||
{% else %}
|
||||
{{ _("Please wait for the package to be approved.") }}
|
||||
Please wait for the package to be approved.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
|
||||
{{ _("You can now submit this package for approval if you're ready.") }}
|
||||
You can now submit this package for approval if you're ready.
|
||||
{% else %}
|
||||
{{ _("This package can be submitted for approval when ready.") }}
|
||||
This package can be submitted for approval when ready.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endset %}
|
||||
|
@ -93,27 +82,22 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if conflicting_modnames %}
|
||||
{% if similar_topics %}
|
||||
<div class="alert alert-warning">
|
||||
<a class="float-right btn btn-sm btn-warning" href="{{ package.getURL('packages.similar') }}">
|
||||
More info
|
||||
</a>
|
||||
{% if conflicting_modnames | length > 4 %}
|
||||
{{ _("Please make sure that this package has the right to the names it uses.") }}
|
||||
{% else %}
|
||||
{{ _("Please make sure that this package has the right to the names %(names)s", names=conflicting_modnames | join(", ")) }}.
|
||||
{% endif %}
|
||||
Please make sure that this package has the right to
|
||||
the name '{{ package.name }}'.
|
||||
See the
|
||||
<a href="/policy_and_guidance/">Inclusion Policy</a>
|
||||
for more info.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not package.review_thread and (package.author == current_user or package.checkPerm(current_user, "APPROVE_NEW")) %}
|
||||
<div class="alert alert-secondary">
|
||||
<a class="float-right btn btn-sm btn-secondary" href="{{ url_for('threads.new', pid=package.id, title='Package approval comments') }}">
|
||||
{{ _("Open Thread") }}
|
||||
</a>
|
||||
<a class="float-right btn btn-sm btn-secondary" href="{{ url_for('threads.new', pid=package.id, title='Package approval comments') }}">Open Thread</a>
|
||||
|
||||
{{ _("Package approval thread") }}:
|
||||
{{ _("You can open a thread if you have a question for the approver or package author.") }}
|
||||
{{ _("Package review thread") }}:
|
||||
{{ _("You can open a thread if you have a question for the reviewer or package author.") }}
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% macro render_pkgtile(package, show_author) -%}
|
||||
<li class="packagetile flex-fill"><a href="{{ package.getURL("packages.view") }}"
|
||||
<li class="packagetile flex-fill"><a href="{{ package.getDetailsURL() }}"
|
||||
style="background-image: url({{ package.getThumbnailOrPlaceholder(2) }});">
|
||||
<div class="packagegridscrub"></div>
|
||||
<div class="packagegridinfo">
|
||||
|
@ -9,11 +9,6 @@
|
|||
{% if show_author %}<br />
|
||||
<small>{{ package.author.display_name }}</small>
|
||||
{% endif %}
|
||||
{% if not package.approved %}
|
||||
<span class="badge ml-1 {% if package.state == package.state.CHANGES_NEEDED %}bg-danger{% else %}bg-warning{% endif %}">
|
||||
{{ package.state.value }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
|
@ -22,15 +17,15 @@
|
|||
|
||||
{% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %}
|
||||
<p style="color:#f33;">
|
||||
{{ _("<b>Warning:</b> Non-free code and media.") }}
|
||||
<b>Warning:</b> Non-free code and media.
|
||||
</p>
|
||||
{% elif not package.license.is_foss and package.type != package.type.TXP %}
|
||||
<p style="color:#f33;">
|
||||
{{ _("<b>Warning:</b> Non-free code.") }}
|
||||
<b>Warning:</b> Non-free code.
|
||||
</p>
|
||||
{% elif not package.media_license.is_foss %}
|
||||
<p style="color:#f33;">
|
||||
{{ _("<b>Warning:</b> Non-free media.") }}
|
||||
<b>Warning:</b> Non-free media.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -42,7 +37,7 @@
|
|||
{% for p in packages %}
|
||||
{{ render_pkgtile(p, show_author) }}
|
||||
{% else %}
|
||||
<li class="packagetile flex-fill"><i>{{ _("No packages available") }}</i></li>
|
||||
<li class="packagetile flex-fill"><i>No packages available</i></li>
|
||||
{% endfor %}
|
||||
{% if packages %}
|
||||
{% for i in range(4) %}
|
||||
|
|
|
@ -1,35 +1,22 @@
|
|||
{% macro render_releases_edit(releases, package) %}
|
||||
{% for rel in releases %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ rel.getEditURL() }}">
|
||||
{{ rel.title }}
|
||||
<span class="text-muted ml-1">
|
||||
{% if rel.min_rel and rel.max_rel %}
|
||||
[MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}]
|
||||
{% elif rel.min_rel %}
|
||||
[MT {{ rel.min_rel.name }}+]
|
||||
{% elif rel.max_rel %}
|
||||
[MT ≤{{ rel.max_rel.name }}]
|
||||
{% endif %}
|
||||
</span>
|
||||
<br />
|
||||
<small style="color:#999;">
|
||||
{% if rel.commit_hash %}
|
||||
[{{ rel.commit_hash | truncate(5, end='') }}]
|
||||
{% endif %}
|
||||
|
||||
{{ _("created %(date)s", date=rel.releaseDate | date) }}.
|
||||
</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro render_releases_download(releases, package, current_user) %}
|
||||
{% macro render_releases(releases, package, current_user) -%}
|
||||
{% for rel in releases %}
|
||||
{% if rel.approved or package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE") %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ rel.getDownloadURL() }}">
|
||||
{{ rel.title }}
|
||||
<span class="text-muted ml-1">
|
||||
<li class="list-group-item">
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE") %}
|
||||
<a class="btn btn-sm btn-primary float-right" href="{{ rel.getEditURL() }}">Edit
|
||||
{% if not rel.task_id and not rel.approved and rel.checkPerm(current_user, "APPROVE_RELEASE") %}
|
||||
/ Approve
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if not rel.approved %}<i>{% endif %}
|
||||
|
||||
<a href="{{ rel.getDownloadURL() }}" rel="nofollow" download="{{ rel.getDownloadFileName() }}">
|
||||
{{ rel.title }}
|
||||
</a>
|
||||
|
||||
<span style="color:#ddd;">
|
||||
{% if rel.min_rel and rel.max_rel %}
|
||||
[MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}]
|
||||
{% elif rel.min_rel %}
|
||||
|
@ -38,69 +25,27 @@
|
|||
[MT ≤{{ rel.max_rel.name }}]
|
||||
{% endif %}
|
||||
</span>
|
||||
<br />
|
||||
|
||||
<br>
|
||||
|
||||
<small style="color:#999;">
|
||||
{% if rel.commit_hash %}
|
||||
[{{ rel.commit_hash | truncate(5, end='') }}]
|
||||
{% endif %}
|
||||
|
||||
{{ _("created %(date)s", date=rel.releaseDate | date) }}.
|
||||
created {{ rel.releaseDate | date }}.
|
||||
</small>
|
||||
</a>
|
||||
{% if (package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE")) and rel.task_id %}
|
||||
<a href="{{ url_for('tasks.check', id=rel.task_id, r=package.getDetailsURL()) }}">Importing...</a>
|
||||
{% elif not rel.approved %}
|
||||
Waiting for approval.
|
||||
{% endif %}
|
||||
|
||||
{% if not rel.approved %}</i>{% endif %}
|
||||
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro render_releases(releases, package, current_user) -%}
|
||||
{% for rel in releases %}
|
||||
<div class="list-group-item">
|
||||
<a class="btn btn-sm btn-primary float-right" href="{{ rel.getEditURL() }}">
|
||||
{% if not rel.task_id and not rel.approved and rel.checkPerm(current_user, "APPROVE_RELEASE") %}
|
||||
{{ _("Edit / Approve") }}
|
||||
{% else %}
|
||||
{{ _("Edit") }}
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
{% if not rel.approved %}<i>{% endif %}
|
||||
|
||||
<a href="{{ rel.getDownloadURL() }}" rel="nofollow" download="{{ rel.getDownloadFileName() }}">
|
||||
{{ rel.title }}
|
||||
</a>
|
||||
|
||||
<span class="text-muted ml-1">
|
||||
{% if rel.min_rel and rel.max_rel %}
|
||||
[MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}]
|
||||
{% elif rel.min_rel %}
|
||||
[MT {{ rel.min_rel.name }}+]
|
||||
{% elif rel.max_rel %}
|
||||
[MT ≤{{ rel.max_rel.name }}]
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<br>
|
||||
|
||||
<small style="color:#999;">
|
||||
{% if rel.commit_hash %}
|
||||
[{{ rel.commit_hash | truncate(5, end='') }}]
|
||||
{% endif %}
|
||||
|
||||
{{ _("created %(date)s", date=rel.releaseDate | date) }}.
|
||||
</small>
|
||||
{% if (package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE")) and rel.task_id %}
|
||||
<a href="{{ url_for('tasks.check', id=rel.task_id, r=package.getURL("packages.view")) }}">
|
||||
{{ _("Importing...") }}
|
||||
</a>
|
||||
{% elif not rel.approved %}
|
||||
{{ _("Waiting for approval.") }}
|
||||
{% endif %}
|
||||
|
||||
{% if not rel.approved %}</i>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-group-item">
|
||||
{{ _("No releases available.") }}
|
||||
</div>
|
||||
<li class="list-group-item">No releases available.</li>
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
|
|
@ -1,30 +1,7 @@
|
|||
{% macro render_review_vote(review, current_user, next_url) %}
|
||||
{% set (positive, negative, is_positive) = review.get_totals(current_user) %}
|
||||
<form class="-group" method="post" action="{{ review.getVoteUrl(next_url) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="btn-group">
|
||||
<button class="btn {% if is_positive == true %}btn-primary{% else %}btn-secondary{% endif %}" name="is_positive" value="yes">
|
||||
{{ _("Helpful") }}
|
||||
{% if positive > 0 %}
|
||||
<span class="badge badge-light ml-1">{{ positive }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
<button class="btn {% if is_positive == false %}btn-primary{% else %}btn-secondary{% endif %}" name="is_positive" value="no">
|
||||
{{ _("Unhelpful") }}
|
||||
{% if negative > 0 %}
|
||||
<span class="badge badge-light ml-1">{{ negative }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_reviews(reviews, current_user, show_package_link=False) -%}
|
||||
<ul class="comments mt-4 mb-0">
|
||||
{% for review in reviews %}
|
||||
{% set review_anchor = "review-" + (review.id | string) %}
|
||||
<li class="row my-2 mx-0">
|
||||
<a id="{{ review_anchor }}"></a>
|
||||
<div class="col-md-1 p-1">
|
||||
<a href="{{ url_for('users.profile', username=review.author.username) }}">
|
||||
<img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ review.author.getProfilePicURL() }}">
|
||||
|
@ -56,7 +33,7 @@
|
|||
<div class="card-body markdown">
|
||||
{% if current_user == review.author %}
|
||||
<a class="btn btn-primary btn-sm ml-1 float-right"
|
||||
href="{{ review.package.getURL("packages.review") }}">
|
||||
href="{{ review.package.getReviewURL() }}">
|
||||
<i class="fas fa-pen"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -67,23 +44,21 @@
|
|||
|
||||
{{ reply.comment | markdown }}
|
||||
|
||||
<div class="btn-toolbar mt-2 mb-0">
|
||||
<p class="mt-2 mb-0">
|
||||
{% if show_package_link %}
|
||||
<a class="btn btn-primary mr-1" href="{{ review.package.getURL("packages.view") }}">
|
||||
<a class="btn btn-primary mr-1" href="{{ review.package.getDetailsURL() }}">
|
||||
{{ _("%(title)s by %(author)s",
|
||||
title="<b>" | safe + review.package.title + "</b>" | safe,
|
||||
author=review.package.author.display_name) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="btn {% if review.thread.replies.count() > 1 %} btn-primary {% else %} btn-secondary {% endif %} mr-1"
|
||||
<a class="btn {% if review.thread.replies.count() > 1 %} btn-primary {% else %} btn-secondary {% endif %}"
|
||||
href="{{ url_for('threads.view', id=review.thread.id) }}">
|
||||
<i class="fas fa-comments mr-2"></i>
|
||||
{{ _("%(num)d comments", num=review.thread.replies.count() - 1) }}
|
||||
</a>
|
||||
|
||||
{{ render_review_vote(review, current_user, url_set_anchor(review_anchor)) }}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -103,10 +78,10 @@
|
|||
<div class="card-header">
|
||||
{{ _("Review") }}
|
||||
</div>
|
||||
<form method="post" action="{{ package.getURL("packages.review") }}" class="card-body">
|
||||
<form method="post" action="{{ package.getReviewURL() }}" class="card-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<p>
|
||||
{{ _("Do you recommend this %(type)s?", type=package.type.text | lower) }}
|
||||
{{ _("Do you recommend this %(type)s?", type=package.type.value | lower) }}
|
||||
</p>
|
||||
|
||||
<div class="btn-group btn-group-toggle" data-toggle="buttons">
|
||||
|
@ -142,10 +117,10 @@
|
|||
<div class="card-header">
|
||||
{{ _("Review") }}
|
||||
</div>
|
||||
<form method="post" action="{{ package.getURL("packages.review") }}" class="card-body">
|
||||
<form method="post" action="{{ package.getReviewURL() }}" class="card-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<p>
|
||||
{{ _("Do you recommend this %(type)s?", type=package.type.text | lower) }}
|
||||
{{ _("Do you recommend this %(type)s?", type=package.type.value | lower) }}
|
||||
</p>
|
||||
|
||||
<div class="btn-group">
|
||||
|
|