Compare commits
89 Commits
reserved_n
...
master
Author | SHA1 | Date |
---|---|---|
rubenwardy | 8ad066409c | |
rubenwardy | 4ac8949c3a | |
rubenwardy | 83b2cf48d4 | |
rubenwardy | 2bbb117eac | |
rubenwardy | f61112a8d7 | |
rubenwardy | 3566b030c5 | |
rubenwardy | 2d54fe4ed7 | |
rubenwardy | 7fdd2cc7c9 | |
rubenwardy | 81a85cbbe5 | |
rubenwardy | 4902436b6b | |
rubenwardy | b82bcb0af9 | |
rubenwardy | eeea5d004a | |
rubenwardy | 97ee0a9f85 | |
rubenwardy | 958f92fd63 | |
rubenwardy | dfef268b05 | |
rubenwardy | e7d2f09eb4 | |
rubenwardy | 5bb9012655 | |
rubenwardy | a291b2cd6f | |
rubenwardy | ead077fb92 | |
rubenwardy | 1c9d6ac865 | |
rubenwardy | d098ee9dff | |
rubenwardy | b8d95dd222 | |
rubenwardy | 7c93db95a3 | |
rubenwardy | d529634b7f | |
Y.W | 765b5603c1 | |
Gao Tiesuan | eec39a3fc5 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 72f66530aa | |
Nikita Epifanov | 99ee1cfc7e | |
rubenwardy | f8e82b63e3 | |
rubenwardy | afdf06b3f6 | |
rubenwardy | d21a86587f | |
rubenwardy | 38071165d1 | |
rubenwardy | 1cfc152d3b | |
rubenwardy | 2db2f61992 | |
Balázs Kovács | 4543f6ca39 | |
Nikita Epifanov | f8d518300d | |
Andrij Mizyk | 347e214944 | |
Mikitko | 99b4d8e084 | |
Nikita Epifanov | 313cab6b2d | |
debiankaios | 494559cfd7 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | e3326aa0f1 | |
rubenwardy | bdd3ab4360 | |
rubenwardy | 4f9ec2e8a4 | |
rubenwardy | 14fd30c4f4 | |
rubenwardy | a7103b5b35 | |
rubenwardy | f6ce676e7e | |
rubenwardy | c2fbf7603a | |
rubenwardy | c3a4ea239c | |
rubenwardy | e2708933d3 | |
rubenwardy | cb2d9d4b07 | |
rubenwardy | 1ba70226b8 | |
rubenwardy | d08710684d | |
rubenwardy | 625e4cf9ee | |
rubenwardy | c8b310ebdb | |
rubenwardy | d971dd6700 | |
rubenwardy | e20863a7e1 | |
rubenwardy | 8f2a87e5ed | |
rubenwardy | ae88360e20 | |
rubenwardy | 7d97c2a27b | |
rubenwardy | 02b7d55c2d | |
rubenwardy | 55b5893cce | |
rubenwardy | 1018e1c29c | |
rubenwardy | e5a4161e76 | |
rubenwardy | a3f437e482 | |
rubenwardy | 9fcbbdc472 | |
rubenwardy | 7aac597216 | |
rubenwardy | 95b3c66366 | |
rubenwardy | 3b354de2fc | |
rubenwardy | 411392eb76 | |
rubenwardy | 15c3e4edec | |
rubenwardy | fa0572ae44 | |
rubenwardy | ade75ace49 | |
Hugo Locurcio | 56539bb369 | |
Y.W | 1c63bf0beb | |
pampogo kiraly | b10949d8cd | |
debiankaios | 853cc3ff6e | |
rubenwardy | a0cc6eb997 | |
J. Lavoie | 8b18e6f86d | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 68e4d98bc5 | |
rubenwardy | 390bf7a657 | |
rubenwardy | deb5c02ce6 | |
rubenwardy | 004c5cd383 | |
rubenwardy | 7b4254da58 | |
rubenwardy | d4903f04f1 | |
debiankaios | f2b544ae68 | |
Lemente | ec91295677 | |
rubenwardy | 4943fbd776 | |
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi | 2478df8c0d | |
rubenwardy | 85a178d90e |
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.6
|
||||
FROM python:3.10
|
||||
|
||||
RUN groupadd -g 5123 cdb && \
|
||||
useradd -r -u 5123 -g cdb cdb
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
Content database for Minetest mods, games, and more.\
|
||||
Developed by rubenwardy, license AGPLv3.0+.
|
||||
|
||||
See [Getting Started](docs/getting_started.md).
|
||||
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.
|
||||
|
||||
## How-tos
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ app.config["LANGUAGES"] = {
|
|||
"fr": "Français",
|
||||
"id": "Bahasa Indonesia",
|
||||
"ms": "Bahasa Melayu",
|
||||
"ru": "русский язык",
|
||||
}
|
||||
|
||||
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
||||
|
@ -65,7 +66,7 @@ login_manager.init_app(app)
|
|||
login_manager.login_view = "users.login"
|
||||
|
||||
|
||||
from .sass import sass
|
||||
from .sass import init_app as sass
|
||||
sass(app)
|
||||
|
||||
|
||||
|
@ -121,16 +122,28 @@ def page_not_found(e):
|
|||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
if not request:
|
||||
return None
|
||||
|
||||
locales = app.config["LANGUAGES"].keys()
|
||||
|
||||
if request:
|
||||
locale = request.cookies.get("locale")
|
||||
if locale in locales:
|
||||
return locale
|
||||
if current_user.is_authenticated and current_user.locale in locales:
|
||||
return current_user.locale
|
||||
|
||||
return request.accept_languages.best_match(locales)
|
||||
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
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/set-locale/", methods=["POST"])
|
||||
|
@ -152,4 +165,8 @@ def set_locale():
|
|||
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
|
||||
|
|
|
@ -16,20 +16,26 @@
|
|||
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from celery import group
|
||||
from flask import *
|
||||
from sqlalchemy import or_
|
||||
from flask import redirect, url_for, flash, current_app, jsonify
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import *
|
||||
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__
|
||||
|
@ -42,20 +48,21 @@ def action(title: str):
|
|||
|
||||
return func
|
||||
|
||||
|
||||
@action("Delete stuck releases")
|
||||
def del_stuck_releases():
|
||||
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
|
||||
PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Check releases")
|
||||
|
||||
@action("Check ZIP releases")
|
||||
def check_releases():
|
||||
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))
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
|
@ -65,14 +72,14 @@ def check_releases():
|
|||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
@action("Reimport packages")
|
||||
|
||||
@action("Check the first release of all packages")
|
||||
def reimport_packages():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state!=PackageState.DELETED).all():
|
||||
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))
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
|
@ -82,42 +89,46 @@ def reimport_packages():
|
|||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
@action("Import topic list")
|
||||
|
||||
@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==None) \
|
||||
.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("Clean uploads")
|
||||
|
||||
@action("Remove unused uploads")
|
||||
def clean_uploads():
|
||||
upload_dir = app.config['UPLOAD_DIR']
|
||||
upload_dir = current_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()
|
||||
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 = getURLsFromDB(PackageRelease.url)
|
||||
screenshot_urls = getURLsFromDB(PackageScreenshot.url)
|
||||
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)
|
||||
|
@ -136,7 +147,8 @@ def clean_uploads():
|
|||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Delete metapackages")
|
||||
|
||||
@action("Delete unused metapackages")
|
||||
def del_meta_packages():
|
||||
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
|
||||
count = query.count()
|
||||
|
@ -146,6 +158,7 @@ def del_meta_packages():
|
|||
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)
|
||||
|
@ -158,24 +171,6 @@ def del_removed_packages():
|
|||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Add update config")
|
||||
def add_update_config():
|
||||
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"))
|
||||
|
||||
@action("Run update configs")
|
||||
def run_update_config():
|
||||
|
@ -184,29 +179,31 @@ def run_update_config():
|
|||
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)
|
||||
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)))
|
||||
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)) \
|
||||
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:
|
||||
if len(packages_list) + 54 > 100:
|
||||
packages_list = packages_list[0:(100-54-1)] + "…"
|
||||
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
|
@ -214,6 +211,7 @@ def remind_wip():
|
|||
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(
|
||||
|
@ -234,6 +232,7 @@ def remind_outdated():
|
|||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Import licenses from SPDX")
|
||||
def import_licenses():
|
||||
renames = {
|
||||
|
@ -284,7 +283,56 @@ def import_licenses():
|
|||
|
||||
@action("Delete inactive users")
|
||||
def delete_inactive_users():
|
||||
users = User.query.filter(User.is_active==False, User.packages==None, User.forum_topics==None, User.rank==UserRank.NOT_JOINED).all()
|
||||
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()
|
||||
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,10 +14,10 @@
|
|||
# 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 import redirect, render_template, url_for, request, flash
|
||||
from flask_login import current_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.utils import rank_required, addAuditLog, addNotification, get_system_user
|
||||
from . import bp
|
||||
|
@ -48,9 +48,10 @@ def admin_page():
|
|||
else:
|
||||
flash("Unknown action: " + action, "danger")
|
||||
|
||||
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).all()
|
||||
deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
|
||||
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
|
||||
|
||||
|
||||
class SwitchUserForm(FlaskForm):
|
||||
username = StringField("Username")
|
||||
submit = SubmitField("Switch")
|
||||
|
@ -69,14 +70,13 @@ 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,7 +86,7 @@ def send_bulk_notification():
|
|||
form = SendNotificationForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk notification", None, None, form.title.data)
|
||||
"Sent bulk notification", url_for("admin.admin_page"), 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)
|
||||
|
@ -121,5 +121,10 @@ def restore():
|
|||
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)
|
||||
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,18 +14,17 @@
|
|||
# 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 import request, abort, url_for, redirect, render_template, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import TextAreaField, SubmitField, StringField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.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):
|
||||
|
@ -55,7 +54,7 @@ def send_single_email():
|
|||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
task = send_user_email.delay(user.email, form.subject.data, text, html)
|
||||
task = send_user_email.delay(user.email, user.locale or "en",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)
|
||||
|
@ -67,12 +66,12 @@ def send_bulk_email():
|
|||
form = SendEmailForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk email", None, None, form.text.data)
|
||||
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
for user in User.query.filter(User.email != None).all():
|
||||
send_user_email.delay(user.email, form.subject.data, text, html)
|
||||
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)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
|
|
@ -15,15 +15,14 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.fields.html5 import URLField
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, BooleanField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required, nonEmptyOrNone
|
||||
from . import bp
|
||||
from ...models import UserRank, License, db
|
||||
|
||||
|
||||
@bp.route("/licenses/")
|
||||
|
@ -31,11 +30,13 @@ from . import bp
|
|||
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")
|
||||
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
|
||||
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 *
|
||||
from flask import redirect, render_template, abort, url_for, request
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.models import *
|
||||
from . import bp
|
||||
from ...models import Permission, Tag, db
|
||||
|
||||
|
||||
@bp.route("/tags/")
|
||||
|
@ -40,12 +40,14 @@ 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")])
|
||||
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")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/tags/new/", methods=["GET", "POST"])
|
||||
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
|
||||
|
|
|
@ -15,14 +15,14 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, IntegerField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, MinetestRelease, db
|
||||
|
||||
|
||||
@bp.route("/versions/")
|
||||
|
@ -30,10 +30,12 @@ from . import bp
|
|||
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 *
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, ContentWarning, db
|
||||
|
||||
|
||||
@bp.route("/admin/warnings/")
|
||||
|
@ -30,11 +30,14 @@ from . import bp
|
|||
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"])
|
||||
|
|
|
@ -13,13 +13,14 @@
|
|||
#
|
||||
# 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 subqueryload, joinedload
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import csrf
|
||||
|
@ -30,7 +31,8 @@ from app.querybuilder import QueryBuilder
|
|||
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
|
@ -302,7 +304,7 @@ 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)
|
||||
return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
|
||||
|
@ -355,7 +357,7 @@ def order_screenshots(token: APIToken, package: Package):
|
|||
error(401, "Authentication needed")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to delete 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")
|
||||
|
@ -367,6 +369,28 @@ 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
|
||||
|
@ -477,6 +501,26 @@ def homepage():
|
|||
})
|
||||
|
||||
|
||||
@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():
|
||||
|
|
|
@ -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
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
|
||||
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, reason="API"):
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, 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, reason)
|
||||
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
|
@ -94,6 +94,17 @@ 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")
|
||||
|
|
|
@ -20,7 +20,7 @@ from flask_babel import lazy_gettext
|
|||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import db, User, APIToken, Package, Permission
|
||||
|
|
|
@ -53,12 +53,11 @@ def view(name):
|
|||
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
|
||||
.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()
|
||||
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()
|
||||
|
||||
return render_template("metapackages/view.html", mpackage=mpackage,
|
||||
dependers=dependers, optional_dependers=optional_dependers,
|
||||
|
|
|
@ -65,4 +65,4 @@ def get_package_tabs(user: User, package: Package):
|
|||
]
|
||||
|
||||
|
||||
from . import packages, screenshots, releases, reviews
|
||||
from . import packages, screenshots, releases, reviews, game_hub
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
# 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)
|
|
@ -24,7 +24,7 @@ from flask_login import login_required
|
|||
from sqlalchemy import or_, func
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.querybuilder import QueryBuilder
|
||||
|
@ -115,9 +115,6 @@ def getReleases(package):
|
|||
@bp.route("/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def view(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
show_similar = not package.approved and (
|
||||
current_user in package.maintainers or
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW))
|
||||
|
@ -208,9 +205,6 @@ def shield(package, type):
|
|||
@bp.route("/packages/<author>/<name>/download/")
|
||||
@is_package_page
|
||||
def download(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
release = package.getDownloadRelease()
|
||||
|
||||
if release is None:
|
||||
|
@ -250,6 +244,7 @@ class PackageForm(FlaskForm):
|
|||
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"))
|
||||
|
||||
|
@ -288,15 +283,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 = list(package.tags)
|
||||
form.content_warnings.data = list(package.content_warnings)
|
||||
form.tags.data = package.tags
|
||||
form.content_warnings.data = package.content_warnings
|
||||
|
||||
if request.method == "POST" and form.type.data == PackageType.TXP:
|
||||
form.license.data = form.media_license.data
|
||||
|
@ -333,6 +328,7 @@ 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:
|
||||
|
@ -591,9 +587,6 @@ def alias_create_edit(package: Package, alias_id: int = None):
|
|||
@login_required
|
||||
@is_package_page
|
||||
def share(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
return render_template("packages/share.html", package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="share")
|
||||
|
||||
|
@ -601,9 +594,6 @@ def share(package):
|
|||
@bp.route("/packages/<author>/<name>/similar/")
|
||||
@is_package_page
|
||||
def similar(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
packages_modnames = {}
|
||||
for metapackage in package.provides:
|
||||
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
|
||||
|
|
|
@ -20,7 +20,7 @@ from flask_babel import gettext, lazy_gettext
|
|||
from flask_login import login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
||||
|
@ -33,9 +33,6 @@ from . import bp, get_package_tabs
|
|||
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
|
||||
@is_package_page
|
||||
def list_releases(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
return render_template("packages/releases_list.html",
|
||||
package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="releases")
|
||||
|
@ -52,7 +49,7 @@ def get_mt_releases(is_max):
|
|||
|
||||
|
||||
class CreatePackageReleaseForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
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"))
|
||||
|
@ -60,7 +57,8 @@ class CreatePackageReleaseForm(FlaskForm):
|
|||
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()],
|
||||
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(lazy_gettext("Save"))
|
||||
|
||||
|
||||
class EditPackageReleaseForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
|
@ -110,9 +108,6 @@ def create_release(package):
|
|||
@bp.route("/packages/<author>/<name>/releases/<id>/download/")
|
||||
@is_package_page
|
||||
def download_release(package, id):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
|
|
@ -25,8 +25,8 @@ 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
|
||||
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required
|
||||
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
|
||||
|
||||
|
||||
|
@ -54,9 +54,6 @@ def review(package):
|
|||
flash(gettext("You can't review your own package!"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
|
||||
form = ReviewForm(formdata=request.form, obj=review)
|
||||
|
@ -129,14 +126,19 @@ def review(package):
|
|||
form=form, package=package, review=review)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review/delete/", methods=["POST"])
|
||||
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_review(package):
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
def delete_review(package, reviewer):
|
||||
review = PackageReview.query \
|
||||
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
|
||||
.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()
|
||||
|
@ -147,10 +149,17 @@ def delete_review(package):
|
|||
|
||||
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())
|
||||
|
@ -228,4 +237,4 @@ def review_votes(package):
|
|||
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)
|
||||
user_biases=user_biases_info)
|
||||
|
|
|
@ -20,7 +20,7 @@ from flask_babel import gettext, lazy_gettext
|
|||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.utils import *
|
||||
|
@ -87,7 +87,7 @@ def create_screenshot(package):
|
|||
form = CreateScreenshotForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
|
||||
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data, False)
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
|
|
@ -54,7 +54,8 @@ def report():
|
|||
|
||||
task = None
|
||||
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
|
||||
task = send_user_email.delay(admin.email, f"User report from {user_info}", text)
|
||||
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)
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
from celery import uuid
|
||||
from flask import *
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import *
|
||||
from app.querybuilder import QueryBuilder
|
||||
|
@ -168,6 +168,11 @@ 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))) \
|
||||
|
@ -180,12 +185,14 @@ def view_user(username=None):
|
|||
.all()
|
||||
|
||||
needs_tags = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED) \
|
||||
.filter_by(tags=None).order_by(db.asc(Package.title)).all()
|
||||
.filter(Package.state != PackageState.DELETED, Package.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)
|
||||
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)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
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_
|
||||
|
@ -142,7 +142,7 @@ def handle_register(form):
|
|||
|
||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
||||
if user_by_email:
|
||||
send_anon_email.delay(form.email.data, gettext("Email already in use"),
|
||||
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"))
|
||||
|
@ -168,7 +168,7 @@ def handle_register(form):
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
|
@ -209,25 +209,11 @@ def forgot_password():
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
else:
|
||||
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>
|
||||
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>
|
||||
""")
|
||||
html = render_template("emails/unable_to_find_account.html")
|
||||
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
|
||||
html, html)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
|
@ -269,7 +255,7 @@ def handle_set_password(form):
|
|||
|
||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
||||
if user_by_email:
|
||||
send_anon_email.delay(form.email.data, gettext("Email already in use"),
|
||||
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:
|
||||
|
@ -282,7 +268,7 @@ def handle_set_password(form):
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
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"))
|
||||
|
@ -360,6 +346,7 @@ def verify_email():
|
|||
|
||||
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."))
|
||||
|
||||
|
@ -401,7 +388,7 @@ def unsubscribe_verify():
|
|||
|
||||
sub.token = randomString(32)
|
||||
db.session.commit()
|
||||
send_unsubscribe_verify.delay(form.email.data)
|
||||
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
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_
|
||||
|
@ -156,7 +156,7 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(newEmail, token)
|
||||
send_verify_email.delay(newEmail, token, get_locale().language)
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
db.session.commit()
|
||||
|
@ -342,7 +342,7 @@ def modtools_set_email(username):
|
|||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(user.email, token)
|
||||
send_verify_email.delay(user.email, token, user.locale or "en")
|
||||
|
||||
flash(f"Set email and sent a password reset on {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
|
|
|
@ -89,6 +89,8 @@ 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.
|
||||
|
@ -224,6 +226,7 @@ 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)
|
||||
|
@ -231,12 +234,16 @@ 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.
|
||||
|
@ -248,6 +255,11 @@ 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/ \
|
||||
|
@ -257,6 +269,11 @@ 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 }"
|
||||
```
|
||||
|
||||
|
||||
|
@ -329,9 +346,11 @@ Supported query parameters:
|
|||
### Tags
|
||||
|
||||
* GET `/api/tags/` ([View](/api/tags/)): List of:
|
||||
* `name`: technical name
|
||||
* `title`: human-readable title
|
||||
* `description`: tag description or null
|
||||
* `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.
|
||||
|
||||
### Content Warnings
|
||||
|
||||
|
@ -375,3 +394,5 @@ 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
|
||||
|
|
|
@ -25,8 +25,8 @@ A flag can be:
|
|||
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, WIP packages, and deprecated packages
|
||||
* `desktop_default`: currently same as `deprecated`. Hides all WIP and deprecated packages
|
||||
* `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
|
||||
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages
|
||||
|
||||
## Content Warnings
|
||||
|
||||
|
|
|
@ -67,7 +67,7 @@ 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 1280x768 pixels).
|
||||
* 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)
|
||||
|
|
|
@ -61,6 +61,7 @@ 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.
|
||||
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
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:**
|
||||
|
@ -9,13 +12,14 @@ title: Privacy Policy
|
|||
* 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 and threads
|
||||
* Comments, threads, and reviews
|
||||
* Audit log actions (such as edits and logins) and their time stamps
|
||||
|
||||
ContentDB collects usernames of content creators from the forums,
|
||||
|
@ -30,10 +34,12 @@ 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.
|
||||
* Provide essential system messages, such as password resets and privacy policy updates.
|
||||
* 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
|
||||
* The audit log is used to record actions that may be harmful.
|
||||
* Preferred language/locale is used to translate emails and the ContentDB interface.
|
||||
* Other information is displayed as part of ContentDB's service.
|
||||
|
||||
## Who has access
|
||||
|
@ -43,7 +49,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.
|
||||
* Emails are visible to moderators and the admin.
|
||||
* Email addresses 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.
|
||||
|
@ -52,11 +58,12 @@ 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 Canada.
|
||||
The ContentDB production server is currently located in Germany.
|
||||
Backups are stored in the UK.
|
||||
Encrypted backups may be stored in other countries, such as the US or EU.
|
||||
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
# 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)
|
|
@ -23,6 +23,7 @@ from app.logic.LogicError import LogicError
|
|||
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
|
||||
License, UserRank, PackageDevState
|
||||
from app.utils import addAuditLog
|
||||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def check(cond: bool, msg: str):
|
||||
|
@ -61,6 +62,7 @@ ALLOWED_FIELDS = {
|
|||
"issue_tracker": str,
|
||||
"issueTracker": str,
|
||||
"forums": int,
|
||||
"video_url": str,
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
|
@ -128,8 +130,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||
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"]:
|
||||
"repo", "website", "issueTracker", "forums", "video_url"]:
|
||||
if key in data:
|
||||
setattr(package, key, data[key])
|
||||
|
||||
|
@ -152,7 +159,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||
raise LogicError(400, "Unknown tag: " + tag_id)
|
||||
|
||||
if not was_web and tag.is_protected:
|
||||
break
|
||||
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))
|
||||
|
|
|
@ -6,9 +6,10 @@ 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, reason: str = None):
|
||||
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, 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:
|
||||
|
@ -27,6 +28,13 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
|
|||
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:
|
||||
|
@ -39,6 +47,10 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
|
|||
|
||||
db.session.commit()
|
||||
|
||||
if is_cover_image:
|
||||
package.cover_image = ss
|
||||
db.session.commit()
|
||||
|
||||
return ss
|
||||
|
||||
|
||||
|
@ -58,3 +70,18 @@ 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")
|
||||
|
|
|
@ -70,10 +70,15 @@ 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, self.getSubject(record), text, html)
|
||||
send_user_email.delay(email, "en", subject, text, html)
|
||||
|
||||
|
||||
def build_handler(app):
|
||||
|
|
|
@ -117,8 +117,8 @@ class ForumTopic(db.Model):
|
|||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="forum_topics")
|
||||
|
||||
wip = db.Column(db.Boolean, server_default="0")
|
||||
discarded = db.Column(db.Boolean, server_default="0")
|
||||
wip = db.Column(db.Boolean, default=False, nullable=False)
|
||||
discarded = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
type = db.Column(db.Enum(PackageType), nullable=False)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
|
|
|
@ -26,6 +26,7 @@ from sqlalchemy_utils.types import TSVectorType
|
|||
|
||||
from . import db
|
||||
from .users import Permission, UserRank, User
|
||||
from .. import app
|
||||
|
||||
|
||||
class PackageQuery(BaseQuery, SearchQueryMixin):
|
||||
|
@ -343,6 +344,25 @@ 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
|
||||
|
||||
|
@ -389,11 +409,18 @@ 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")
|
||||
|
||||
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")
|
||||
|
@ -405,7 +432,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"),
|
||||
lazy=True, order_by=db.asc("package_screenshot_order"), viewonly=True,
|
||||
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)
|
||||
|
@ -448,6 +475,14 @@ 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)
|
||||
|
||||
|
@ -469,6 +504,11 @@ 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,
|
||||
|
@ -527,6 +567,7 @@ 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],
|
||||
|
@ -539,7 +580,15 @@ class Package(db.Model):
|
|||
"release": release and release.id,
|
||||
|
||||
"score": round(self.score * 10) / 10,
|
||||
"downloads": self.downloads
|
||||
"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()
|
||||
]
|
||||
}
|
||||
|
||||
def getThumbnailOrPlaceholder(self, level=2):
|
||||
|
@ -607,10 +656,7 @@ class Package(db.Model):
|
|||
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
|
||||
isApprover = user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
if perm == Permission.SEE_PACKAGE:
|
||||
return self.state == PackageState.APPROVED or isMaintainer or isApprover
|
||||
|
||||
elif perm == Permission.CREATE_THREAD:
|
||||
if perm == Permission.CREATE_THREAD:
|
||||
return user.rank.atLeast(UserRank.MEMBER)
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
|
@ -684,7 +730,8 @@ class Package(db.Model):
|
|||
needsScreenshot = \
|
||||
(self.type == self.type.GAME or self.type == self.type.TXP) and \
|
||||
self.screenshots.count() == 0
|
||||
return self.releases.count() > 0 and not needsScreenshot
|
||||
|
||||
return self.releases.filter(PackageRelease.task_id.is_(None)).count() > 0 and not needsScreenshot
|
||||
|
||||
elif state == PackageState.CHANGES_NEEDED:
|
||||
return self.checkPerm(user, Permission.APPROVE_NEW)
|
||||
|
@ -815,7 +862,13 @@ class Tag(db.Model):
|
|||
|
||||
def getAsDictionary(self):
|
||||
description = self.description if self.description != "" else None
|
||||
return { "name": self.name, "title": self.title, "description": description }
|
||||
return {
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"description": description,
|
||||
"is_protected": self.is_protected,
|
||||
"views": self.views,
|
||||
}
|
||||
|
||||
|
||||
class MinetestRelease(db.Model):
|
||||
|
@ -883,6 +936,10 @@ 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,
|
||||
|
@ -984,6 +1041,9 @@ 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)
|
||||
|
@ -995,6 +1055,22 @@ 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,
|
||||
|
@ -1016,8 +1092,11 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -200,7 +200,8 @@ class PackageReview(db.Model):
|
|||
def getDeleteURL(self):
|
||||
return url_for("packages.delete_review",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name)
|
||||
name=self.package.name,
|
||||
reviewer=self.author.username)
|
||||
|
||||
def getVoteUrl(self, next_url=None):
|
||||
return url_for("packages.review_vote",
|
||||
|
@ -213,6 +214,20 @@ class PackageReview(db.Model):
|
|||
(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)
|
||||
|
|
|
@ -59,7 +59,6 @@ class UserRank(enum.Enum):
|
|||
|
||||
|
||||
class Permission(enum.Enum):
|
||||
SEE_PACKAGE = "SEE_PACKAGE"
|
||||
EDIT_PACKAGE = "EDIT_PACKAGE"
|
||||
DELETE_PACKAGE = "DELETE_PACKAGE"
|
||||
CHANGE_AUTHOR = "CHANGE_AUTHOR"
|
||||
|
@ -87,6 +86,7 @@ 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"
|
||||
|
||||
|
@ -148,6 +148,8 @@ 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")
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// @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/", {
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// @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,3 +1,6 @@
|
|||
// @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");
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
// @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;
|
||||
}
|
||||
});
|
|
@ -75,6 +75,10 @@ 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
|
||||
|
@ -132,6 +136,9 @@ 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))
|
||||
|
||||
|
|
43
app/sass.py
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
|
||||
from flask import *
|
||||
from scss import Scss
|
||||
import sass
|
||||
from flask import send_from_directory
|
||||
|
||||
def _convert(dir, src, dst):
|
||||
|
||||
def _convert(dir_path, src, dst):
|
||||
original_wd = os.getcwd()
|
||||
os.chdir(dir)
|
||||
os.chdir(dir_path)
|
||||
|
||||
css = Scss()
|
||||
source = codecs.open(src, 'r', encoding='utf-8').read()
|
||||
output = css.compile(source)
|
||||
output = sass.compile(string=source)
|
||||
|
||||
os.chdir(original_wd)
|
||||
|
||||
|
@ -29,8 +29,9 @@ def _convert(dir, src, dst):
|
|||
outfile.write(output)
|
||||
outfile.close()
|
||||
|
||||
def _getDirPath(app, originalPath, create=False):
|
||||
path = originalPath
|
||||
|
||||
def _get_dir_path(app, original_path, create=False):
|
||||
path = original_path
|
||||
|
||||
if not os.path.isdir(path):
|
||||
path = os.path.join(app.root_path, path)
|
||||
|
@ -39,25 +40,25 @@ def _getDirPath(app, originalPath, create=False):
|
|||
if create:
|
||||
os.mkdir(path)
|
||||
else:
|
||||
raise IOError("Unable to find " + originalPath)
|
||||
raise IOError("Unable to find " + original_path)
|
||||
|
||||
return path
|
||||
|
||||
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 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(filepath):
|
||||
sassfile = "%s/%s.scss" % (inputDir, filepath)
|
||||
cacheFile = "%s/%s.css" % (cacheDir, filepath)
|
||||
scss_file = "%s/%s.scss" % (input_dir, filepath)
|
||||
cache_file = "%s/%s.css" % (cache_dir, filepath)
|
||||
|
||||
# Source file exists, and needs regenerating
|
||||
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))
|
||||
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))
|
||||
|
||||
return send_from_directory(cacheDir, filepath + ".css")
|
||||
return send_from_directory(cache_dir, filepath + ".css")
|
||||
|
||||
app.add_url_rule("/%s/<path:filepath>.css" % outputPath, 'sass', _sass)
|
||||
app.add_url_rule("/%s/<path:filepath>.css" % dest, 'sass', _sass)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@import "components.scss";
|
||||
@import "packages.scss";
|
||||
@import "gallery.scss";
|
||||
@import "packagegrid.scss";
|
||||
@import "comments.scss";
|
||||
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
.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,32 +1,3 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-tr {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
|
@ -34,23 +5,6 @@
|
|||
color: #ccc !important;;
|
||||
}
|
||||
|
||||
.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 {
|
||||
vertical-align: middle;
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
|
||||
from flask import render_template, escape
|
||||
from flask_babel import force_locale, gettext
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from app.models import Notification, db, EmailSubscription, User
|
||||
|
@ -36,112 +37,121 @@ def get_email_subscription(email):
|
|||
|
||||
|
||||
@celery.task()
|
||||
def send_verify_email(email, token):
|
||||
def send_verify_email(email, token, locale):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message("Confirm email address", recipients=[email])
|
||||
with force_locale(locale or "en"):
|
||||
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.
|
||||
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))
|
||||
|
||||
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)
|
||||
msg.html = render_template("emails/verify.html", token=token, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_unsubscribe_verify(email):
|
||||
def send_unsubscribe_verify(email, locale):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message("Confirm unsubscribe", recipients=[email])
|
||||
with force_locale(locale or "en"):
|
||||
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()
|
||||
def send_email_with_reason(email, subject, text, html, reason):
|
||||
@celery.task(rate_limit="25/m")
|
||||
def send_email_with_reason(email: str, locale: str, subject: str, text: str, html: str, reason: str):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
from flask_mail import Message
|
||||
msg = Message(subject, recipients=[email])
|
||||
with force_locale(locale or "en"):
|
||||
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 f"<pre>{escape(text)}</pre>"
|
||||
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@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_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_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.")
|
||||
@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."))
|
||||
|
||||
|
||||
def send_single_email(notification):
|
||||
def send_single_email(notification, locale):
|
||||
sub = get_email_subscription(notification.user.email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message(notification.title, recipients=[notification.user.email])
|
||||
with force_locale(locale or "en"):
|
||||
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]):
|
||||
def send_notification_digest(notifications: [Notification], locale):
|
||||
user = notifications[0].user
|
||||
|
||||
sub = get_email_subscription(user.email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message("{} new notifications".format(len(notifications)), recipients=[user.email])
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message(gettext("%(num)d new notifications", num=len(notifications)), recipients=[user.email])
|
||||
|
||||
msg.body = "".join(["<{}> {}\nView: {}\n\n".format(notification.causer.display_name, notification.title, abs_url(notification.url)) for notification in notifications])
|
||||
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 += "Manage email settings: {}\nUnsubscribe: {}".format(
|
||||
abs_url_for("users.email_notifications", username=user.username),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
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.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()
|
||||
|
@ -154,7 +164,7 @@ def send_pending_digests():
|
|||
notification.emailed = True
|
||||
|
||||
if len(to_send) > 0:
|
||||
send_notification_digest(to_send)
|
||||
send_notification_digest(to_send, user.locale or "en")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
@ -174,6 +184,6 @@ def send_pending_notifications():
|
|||
db.session.commit()
|
||||
|
||||
if len(to_send) > 1:
|
||||
send_notification_digest(to_send)
|
||||
send_notification_digest(to_send, user.locale or "en")
|
||||
elif len(to_send) > 0:
|
||||
send_single_email(to_send[0])
|
||||
send_single_email(to_send[0], user.locale or "en")
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
#
|
||||
# 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
|
||||
|
@ -22,11 +23,13 @@ from kombu import uuid
|
|||
|
||||
from app.models import *
|
||||
from app.tasks import celery, TaskError
|
||||
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_system_user
|
||||
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog
|
||||
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()
|
||||
|
@ -112,6 +115,11 @@ 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)
|
||||
|
@ -213,6 +221,10 @@ 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()
|
||||
|
||||
|
|
|
@ -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=32">
|
||||
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=34">
|
||||
<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">
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% for type, group in notifications | groupby("package.title") %}
|
||||
{% for title, group in notifications | selectattr("package") | groupby("package.title") %}
|
||||
<h2>
|
||||
{{ type or _("Other Notifications") }}
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
|
@ -17,6 +17,23 @@
|
|||
</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") }}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<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>
|
|
@ -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 %}
|
||||
|
|
|
@ -14,19 +14,24 @@
|
|||
</div>
|
||||
|
||||
{% set level = "warning" %}
|
||||
{% if package.releases.count() == 0 %}
|
||||
{% if package.releases.filter_by(task_id=None).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 first release") }}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.create_release') }}">
|
||||
{{ _("Create release") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL("packages.setup_releases") }}">
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.setup_releases') }}">
|
||||
{{ _("Set up releases") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{{ _("You need to create a release before this package can be approved.") }}
|
||||
|
||||
{% 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.") }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ _("A release is required before this package can be approved.") }}
|
||||
{% endif %}
|
||||
|
|
|
@ -4,18 +4,21 @@
|
|||
{{ mpackage.name }} - {{ _("Meta Packages") }}
|
||||
{% endblock %}
|
||||
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ _("Meta Package \"%(name)s\"", name=mpackage.name) }}</h1>
|
||||
|
||||
<h2>{{ _("Provided By") }}</h2>
|
||||
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
{{ render_pkggrid(mpackage.packages.filter_by(state="APPROVED").all()) }}
|
||||
<h3>{{ _("Games") }}</h3>
|
||||
{{ render_pkggrid(mpackage.packages.filter_by(type="GAME", state="APPROVED").all()) }}
|
||||
|
||||
<h3>{{ _("Mods") }}</h3>
|
||||
{{ render_pkggrid(mpackage.packages.filter_by(type="MOD", state="APPROVED").all()) }}
|
||||
|
||||
{% if similar_topics %}
|
||||
<p>
|
||||
{{ _("Unfortunately, this isn't on ContentDB yet! Here's some forum topic(s):") }}
|
||||
</p>
|
||||
<h3>{{ _("Forum Topics") }}</h3>
|
||||
<ul>
|
||||
{% for t in similar_topics %}
|
||||
<li>
|
||||
|
|
|
@ -117,6 +117,7 @@
|
|||
pattern="[0-9]+",
|
||||
prefix="forum.minetest.net/viewtopic.php?t=",
|
||||
placeholder=_("Tip: paste in a forum topic URL")) }}
|
||||
{{ render_field(form.video_url, class_="pkg_meta", hint=_("YouTube videos will be shown in an embed.")) }}
|
||||
</fieldset>
|
||||
|
||||
<div class="pkg_meta mt-5">{{ render_submit_field(form.submit) }}</div>
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("Community Hub") }} -
|
||||
{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block headextra %}
|
||||
<meta name="og:title" content="{{ self.title() }}"/>
|
||||
<meta name="og:description" content="{{ _('Mods for %(title)s', title=package.title) }}"/>
|
||||
<meta name="description" content="{{ _('Mods for %(title)s', title=package.title) }}"/>
|
||||
<meta name="og:url" content="{{ package.getURL('packages.game_hub', absolute=True) }}"/>
|
||||
{% if package.getMainScreenshotURL() %}
|
||||
<meta name="og:image" content="{{ package.getMainScreenshotURL(absolute=True) }}"/>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
|
||||
<h1 class="mb-5">
|
||||
{{ _("Community Hub") }} -
|
||||
<a href="{{ package.getURL('packages.view') }}">
|
||||
{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<a href="{{ url_for('packages.list_all', sort='approved_at', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Recently Added") }}</h2>
|
||||
{{ render_pkggrid(new) }}
|
||||
|
||||
|
||||
<a href="{{ url_for('packages.list_all', sort='last_release', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Recently Updated") }}</h2>
|
||||
{{ render_pkggrid(updated) }}
|
||||
|
||||
|
||||
<a href="{{ url_for('packages.list_all', type='mod', sort='score', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Top Mods") }}</h2>
|
||||
{{ render_pkggrid(pop_mod) }}
|
||||
|
||||
|
||||
<a href="{{ url_for('packages.list_all', sort='reviews', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Highest Reviewed") }}</h2>
|
||||
{{ render_pkggrid(high_reviewed) }}
|
||||
|
||||
{% endblock %}
|
|
@ -32,8 +32,6 @@
|
|||
<p class="mt-3">
|
||||
{{ _("Note: Min and max versions will be used to hide the package on
|
||||
platforms not within the range.") }}
|
||||
{{ _("You cannot select the oldest version for min or the newest version
|
||||
for max as this does not make sense - you can't predict the future.") }}
|
||||
<br />
|
||||
{{ _("Leave both as None if in doubt.") }}
|
||||
</p>
|
||||
|
|
|
@ -62,11 +62,6 @@
|
|||
|
||||
{{ _("You can <a href='/help/package_config/'>set this automatically</a> in the .conf of your package.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("You cannot select the oldest version for min or the newest version
|
||||
for max as this does not make sense - you can't predict the future.") }}
|
||||
</p>
|
||||
|
||||
<p class="mt-5">
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</p>
|
||||
|
|
|
@ -82,11 +82,6 @@
|
|||
<br />
|
||||
{{ _("Leave both as None if in doubt.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("You cannot select the oldest version for min or the newest version
|
||||
for max as this does not make sense - you can't predict the future.") }}
|
||||
</p>
|
||||
|
||||
<p class="mt-5">
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</p>
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("Add a screenshot") }} | {{ package.title }}
|
||||
{{ _("Add a screenshot") }} - {{ package.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ _("Add a screenshot") }}</h1>
|
||||
<p class="mb-4">
|
||||
{{ _("The recommended resolution is 1920x1080, and screenshots must be at least %(width)dx%(height)d.",
|
||||
width=920, height=517) }}
|
||||
</p>
|
||||
|
||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
{% block content %}
|
||||
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<a href="{{ package.getURL("packages.create_screenshot") }}" class="btn btn-primary float-right">
|
||||
<a href="{{ package.getURL('packages.create_screenshot') }}" class="btn btn-primary float-right">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
{{ _("Add Image") }}
|
||||
</a>
|
||||
|
@ -26,16 +26,34 @@
|
|||
<i class="fas fa-bars"></i>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<img class="img-fluid" style="max-height: 64px;"
|
||||
src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
|
||||
<img class="img-fluid" style="max-height: 64px;" src="{{ ss.getThumbnailURL() }}" />
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ ss.title }}
|
||||
{% if not ss.approved %}
|
||||
<div class="text-muted">
|
||||
{{ _("Awaiting approval") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-1 text-muted">
|
||||
{{ ss.width }} x {{ ss.height }}
|
||||
{% if ss.is_low_res() %}
|
||||
{% if ss.is_very_small() %}
|
||||
<span class="badge badge-danger ml-3">
|
||||
{{ _("Way too small") }}
|
||||
</span>
|
||||
{% elif ss.is_too_small() %}
|
||||
<span class="badge badge-warning ml-3">
|
||||
{{ _("Too small") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary ml-3">
|
||||
{{ _("Not HD") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not ss.approved %}
|
||||
<span class="ml-3">
|
||||
{{ _("Awaiting approval") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form action="{{ ss.getDeleteURL() }}" method="POST" class="col-auto text-right" role="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
|
@ -78,6 +96,11 @@
|
|||
|
||||
{{ render_submit_field(form.submit, tabindex=280) }}
|
||||
</form>
|
||||
|
||||
<h2>{{ _("Videos") }}</h2>
|
||||
<p>
|
||||
{{ _("You can set a video on the Edit Details page") }}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptextra %}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% set query=package.name %}
|
||||
{% set release = package.getDownloadRelease() %}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
|
@ -16,6 +17,10 @@
|
|||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptextra %}
|
||||
<script src="/static/video_embed.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% macro render_license(license) %}
|
||||
{% if license.url %}
|
||||
<a href="{{ license.url }}">{{ license.name }}</a>
|
||||
|
@ -24,6 +29,52 @@
|
|||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block download_btn %}
|
||||
{% if release %}
|
||||
<a class="btn btn-block btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
|
||||
href="{{ package.getURL('packages.download') }}">
|
||||
<div>
|
||||
{{ _("Download") }}
|
||||
</div>
|
||||
|
||||
{% if release and (release.min_rel or release.max_rel) %}
|
||||
<small class="count display-block">
|
||||
{% if release.min_rel and release.max_rel %}
|
||||
{{ _("Minetest %(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
|
||||
{% elif release.min_rel %}
|
||||
{{ _("For Minetest %(min)s and above", min=release.min_rel.name) }}
|
||||
{% elif release.max_rel %}
|
||||
{{ _("Minetest %(max)s and below", max=release.max_rel.name) }}
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
{% if package.type == package.type.MOD %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
|
||||
{% elif package.type == package.type.GAME %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
|
||||
{% elif package.type == package.type.TXP %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
|
||||
{% else %}
|
||||
{{ 0 / 0 }}
|
||||
{% endif %}
|
||||
|
||||
<p class="text-center mt-1 mb-4">
|
||||
<a href="{{ installing_url }}">
|
||||
<small>
|
||||
<i class="fas fa-question-circle mr-1"></i>
|
||||
{{ _("How do I install this?") }}
|
||||
</small>
|
||||
</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<i>
|
||||
{{ _("No downloads available") }}
|
||||
</i>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block container %}
|
||||
{% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %}
|
||||
{% set package_warning=_("Non-free code and media") %}
|
||||
|
@ -32,7 +83,6 @@
|
|||
{% elif not package.media_license.is_foss %}
|
||||
{% set package_warning=_("Non-free media") %}
|
||||
{% endif %}
|
||||
{% set release = package.getDownloadRelease() %}
|
||||
<main>
|
||||
{% set cover_image = package.cover_image.url or package.getMainScreenshotURL() %}
|
||||
<header class="jumbotron pb-3"
|
||||
|
@ -43,19 +93,19 @@
|
|||
<div class="container">
|
||||
<div class="btn-group float-right mb-4">
|
||||
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
|
||||
<a class="btn btn-primary" href="{{ package.getURL("packages.create_edit") }}">
|
||||
<a class="btn btn-primary" href="{{ package.getURL('packages.create_edit') }}">
|
||||
<i class="fas fa-pen mr-1"></i>
|
||||
{{ _("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
|
||||
<a class="btn btn-primary" href="{{ package.getURL("packages.create_release") }}">
|
||||
<a class="btn btn-primary" href="{{ package.getURL('packages.create_release') }}">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
{{ _("Release") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %}
|
||||
<a class="btn btn-danger" href="{{ package.getURL("packages.remove") }}">
|
||||
<a class="btn btn-danger" href="{{ package.getURL('packages.remove') }}">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
{{ _("Remove") }}
|
||||
</a>
|
||||
|
@ -157,46 +207,6 @@
|
|||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if release and (release.min_rel or release.max_rel) %}
|
||||
<div class="btn col-md-auto">
|
||||
<img src="https://www.minetest.net/media/icon.svg" style="max-height: 1.2em;">
|
||||
<span class="count">
|
||||
{% if release.min_rel and release.max_rel %}
|
||||
{{ _("%(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
|
||||
{% elif release.min_rel %}
|
||||
{{ _("%(min)s and above", min=release.min_rel.name) }}
|
||||
{% elif release.max_rel %}
|
||||
{{ _("%(max)s and below", max=release.max_rel.name) }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="btn-group btn-group-horizontal col-md-auto">
|
||||
{% if release %}
|
||||
<a class="btn btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
|
||||
href="{{ package.getURL("packages.download") }}">
|
||||
{{ _("Download") }}
|
||||
</a>
|
||||
{% if package.type == package.type.MOD %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
|
||||
{% elif package.type == package.type.GAME %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
|
||||
{% elif package.type == package.type.TXP %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
|
||||
{% else %}
|
||||
{{ 0 / 0 }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<a href="{{ installing_url }}" class="btn btn-download">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<i>
|
||||
{{ _("No downloads available") }}
|
||||
</i>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
@ -222,37 +232,55 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="container d-block d-md-none">
|
||||
{{ self.download_btn() }}
|
||||
</div>
|
||||
|
||||
<section class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-9" style="padding-right: 45px;">
|
||||
{% set screenshots = package.screenshots.all() %}
|
||||
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<a href="{{ package.getURL("packages.screenshots") }}" class="btn btn-primary float-right">
|
||||
<i class="fas fa-images mr-1"></i>
|
||||
{{ _("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<ul class="screenshot_list">
|
||||
{% for ss in screenshots %}
|
||||
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<li>
|
||||
<a href="{{ ss.url }}" class="position-relative">
|
||||
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
|
||||
{% if not ss.approved %}
|
||||
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<a href="{{ package.getURL('packages.screenshots') }}" class="btn btn-primary float-right">
|
||||
<i class="fas fa-images mr-1"></i>
|
||||
{{ _("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") or package.video_url %}
|
||||
<ul class="gallery">
|
||||
{% if package.video_url %}
|
||||
<li>
|
||||
<a href="{{ package.getURL("packages.create_screenshot") }}">
|
||||
<i class="fas fa-plus screenshot-add"></i>
|
||||
<a href="{{ package.video_url }}" class="video-embed">
|
||||
<i class="fas fa-play"></i>
|
||||
<div class="label">
|
||||
<i class="fas fa-external-link-square-alt"></i>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
{% for ss in screenshots %}
|
||||
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<li>
|
||||
<a href="{{ ss.url }}" class="gallery-image">
|
||||
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
|
||||
{% if not ss.approved %}
|
||||
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="{{ package.getURL('packages.create_screenshot') }}">
|
||||
<i class="fas fa-plus screenshot-add"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
@ -294,9 +322,20 @@
|
|||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
{{ render_pkggrid(packages_uses) }}
|
||||
{% endif %}
|
||||
|
||||
{% if package.type == package.type.GAME %}
|
||||
<h2>{{ _("Content") }}</h2>
|
||||
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-primary">
|
||||
{{ _("View content for game") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<aside class="col-md-3 info-sidebar">
|
||||
<div class="d-none d-md-block">
|
||||
{{ self.download_btn() }}
|
||||
</div>
|
||||
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %}
|
||||
{% set config = package.update_config %}
|
||||
<div class="alert alert-warning">
|
||||
|
@ -339,6 +378,12 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if package.type == package.type.GAME %}
|
||||
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-block mb-4 btn-primary">
|
||||
{{ _("View content for game") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if package.type != package.type.TXP %}
|
||||
<h3>{{ _("Dependencies") }}</h3>
|
||||
<dl>
|
||||
|
@ -387,6 +432,23 @@
|
|||
</dl>
|
||||
{% endif %}
|
||||
|
||||
{% if package.type == package.type.MOD %}
|
||||
<h3>{{ _("Compatible Games") }}</h3>
|
||||
{% for support in package.getSortedSupportedGames() %}
|
||||
<a class="badge badge-secondary"
|
||||
href="{{ support.game.getURL('packages.view') }}">
|
||||
{{ _("%(title)s by %(display_name)s",
|
||||
title=support.game.title, display_name=support.game.author.display_name) }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ _("No specific game is required") }}
|
||||
{% endfor %}
|
||||
<p class="text-muted small mt-2 mb-0">
|
||||
{{ _("This is an experimental feature.") }}
|
||||
{{ _("Supported games are determined by an algorithm, and may not be correct.") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>
|
||||
{{ _("Information") }}
|
||||
</h3>
|
||||
|
@ -482,7 +544,7 @@
|
|||
{% if package.approved and current_user != package.author %}
|
||||
|
|
||||
{% endif %}
|
||||
<a href="{{ package.getURL("packages.audit") }}">
|
||||
<a href="{{ package.getURL('packages.audit') }}">
|
||||
{{ _("See audit log") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
|
|
@ -25,6 +25,9 @@
|
|||
{{ _("Only the admin will be able to see who made the report.") }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="alert alert-info">
|
||||
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -36,10 +36,16 @@
|
|||
<input type="submit" class="btn btn-primary" value="{{ _('Subscribe') }}" />
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if thread and thread.checkPerm(current_user, "DELETE_THREAD") %}
|
||||
{% if thread.checkPerm(current_user, "DELETE_THREAD") %}
|
||||
<a href="{{ url_for('threads.delete_thread', id=thread.id) }}" class="float-right mr-2 btn btn-danger">{{ _('Delete') }}</a>
|
||||
{% endif %}
|
||||
{% if thread and thread.checkPerm(current_user, "LOCK_THREAD") %}
|
||||
{% if thread.review and thread.review.checkPerm(current_user, "DELETE_REVIEW") and current_user.username != thread.review.author.username %}
|
||||
<form method="post" action="{{ thread.review.getDeleteURL() }}" class="float-right mr-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="submit" class="btn btn-danger" value="{{ _('Convert to Thread') }}" />
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if thread.checkPerm(current_user, "LOCK_THREAD") %}
|
||||
{% if thread.locked %}
|
||||
<form method="post" action="{{ url_for('threads.set_lock', id=thread.id, lock=0) }}" class="float-right mr-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ _("Unapproved Packages Needing Action") }}</h2>
|
||||
<div class="list-group mt-3 mb-5">
|
||||
{% for package in unapproved_packages %}
|
||||
|
@ -53,21 +54,75 @@
|
|||
</form>
|
||||
{% endif %}
|
||||
<h2>{{ _("Potentially Outdated Packages") }}</h2>
|
||||
<p class="alert alert-info">
|
||||
{{ _("New: Git Update Detection has been set up on all packages to send notifications.") }}<br />
|
||||
{{ _("Consider changing the update settings to create releases automatically instead.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("Instead of marking packages as outdated, you can automatically create releases when New Commits or New Tags are pushed to Git by clicking 'Update Settings'.") }}
|
||||
{% if outdated_packages %}
|
||||
{{ _("To remove a package from below, create a release or change the update settings.") }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% from "macros/todo.html" import render_outdated_packages %}
|
||||
{{ render_outdated_packages(outdated_packages, current_user) }}
|
||||
|
||||
|
||||
<div class="mt-5"></div>
|
||||
<h2 id="small-screenshots">{{ _("Small Screenshots") }}</h2>
|
||||
{% if packages_with_small_screenshots %}
|
||||
<p>
|
||||
{{ _("These packages have screenshots that are too small, and should be replaced.") }}
|
||||
{{ _("Red and orange are screenshots below the limit, and grey screenshots are below the recommended resolution.") }}
|
||||
{{ _("The recommended resolution is 1920x1080, and screenshots must be at least %(width)dx%(height)d.",
|
||||
width=920, height=517) }}
|
||||
|
||||
<span class="badge badge-danger ml-3">
|
||||
{{ _("Way too small") }}
|
||||
</span>
|
||||
<span class="badge badge-warning">
|
||||
{{ _("Too small") }}
|
||||
</span>
|
||||
<span class="badge badge-secondary">
|
||||
{{ _("Not HD") }}
|
||||
</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="list-group mt-3 mb-5">
|
||||
{% for package in packages_with_small_screenshots %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ package.getURL('packages.screenshots') }}">
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-muted" style="min-width: 200px;">
|
||||
<img
|
||||
class="img-fluid"
|
||||
style="max-height: 22px; max-width: 22px;"
|
||||
src="{{ package.getThumbnailOrPlaceholder() }}" />
|
||||
|
||||
<span class="pl-2">
|
||||
{{ package.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-sm">
|
||||
{% for ss in package.screenshots %}
|
||||
{% if ss.is_low_res() %}
|
||||
{% if ss.is_very_small() %}
|
||||
{% set badge_color = "badge-danger" %}
|
||||
{% elif ss.is_too_small() %}
|
||||
{% set badge_color = "badge-warning" %}
|
||||
{% else %}
|
||||
{% set badge_color = "badge-secondary" %}
|
||||
{% endif %}
|
||||
<span class="badge {{ badge_color }} ml-2" title="{{ ss.title }}">
|
||||
{{ ss.width }} x {{ ss.height }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-muted">{{ _("Nothing to do :)") }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<a class="btn btn-secondary float-right" href="{{ url_for('todo.tags', author=user.username) }}">
|
||||
{{_ ("See All") }}</a>
|
||||
<h2>{{ _("Packages Without Tags") }}</h2>
|
||||
|
|
|
@ -41,8 +41,8 @@
|
|||
</strong>.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
{{ _("ContentDB will no longer be able to send "forget password" and other essential system emails.
|
||||
Consider editing your email notification preferences instead.") }}
|
||||
{{ _('ContentDB will no longer be able to send "forget password" and other essential system emails.
|
||||
Consider editing your email notification preferences instead.') }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def test_clean_youtube_url():
|
||||
assert clean_youtube_url(
|
||||
"https://www.youtube.com/watch?v=AABBCC") == "https://www.youtube.com/watch?v=AABBCC"
|
||||
assert clean_youtube_url(
|
||||
"https://www.youtube.com/watch?v=boGcB4H5-WA&other=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
|
||||
assert clean_youtube_url("https://www.youtube.com/watch?kk=boGcB4H5-WA&other=1") is None
|
||||
assert clean_youtube_url("https://www.bob.com/watch?v=AABBCC") is None
|
||||
|
||||
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
|
||||
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA?this=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
|
|
@ -14,13 +14,12 @@
|
|||
# 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 re
|
||||
import secrets
|
||||
|
||||
from .flask import *
|
||||
from .models import *
|
||||
from .user import *
|
||||
import re
|
||||
|
||||
|
||||
YESES = ["yes", "true", "1", "on"]
|
||||
|
||||
|
|
|
@ -45,6 +45,9 @@ def abs_url_samesite(path):
|
|||
return urlunparse(base._replace(path=path))
|
||||
|
||||
def url_current(abs=False):
|
||||
if request.args is None or request.view_args is None:
|
||||
return None
|
||||
|
||||
args = MultiDict(request.args)
|
||||
dargs = dict(args.lists())
|
||||
dargs.update(request.view_args)
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# 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 typing import Tuple
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def get_image_size(path: str) -> Tuple[int,int]:
|
||||
im = Image.open(path)
|
||||
return im.size
|
|
@ -18,8 +18,7 @@
|
|||
from functools import wraps
|
||||
from flask import abort, redirect, url_for, request
|
||||
from flask_login import current_user
|
||||
from app.models import User, NotificationType, Package, UserRank, Notification, db, AuditSeverity, AuditLogEntry, \
|
||||
ThreadReply, Thread, PackageState, PackageType, PackageAlias
|
||||
from app.models import User, NotificationType, Package, UserRank, Notification, db, AuditSeverity, AuditLogEntry, ThreadReply, Thread, PackageState, PackageType, PackageAlias
|
||||
|
||||
|
||||
def getPackageByInfo(author, name):
|
||||
|
@ -40,15 +39,14 @@ def is_package_page(f):
|
|||
if not ("author" in kwargs and "name" in kwargs):
|
||||
abort(400)
|
||||
|
||||
author = kwargs.pop("author")
|
||||
name = kwargs.pop("name")
|
||||
author = kwargs["author"]
|
||||
name = kwargs["name"]
|
||||
|
||||
package = getPackageByInfo(author, name)
|
||||
if package is None:
|
||||
package = getPackageByInfo(author, name + "_game")
|
||||
if package and package.type == PackageType.GAME:
|
||||
args = dict(kwargs)
|
||||
args["author"] = author
|
||||
args["name"] = name + "_game"
|
||||
return redirect(url_for(request.endpoint, **args))
|
||||
|
||||
|
@ -61,6 +59,8 @@ def is_package_page(f):
|
|||
|
||||
abort(404)
|
||||
|
||||
del kwargs["author"]
|
||||
del kwargs["name"]
|
||||
return f(package=package, *args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
# 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 urllib.parse as urlparse
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
|
||||
def url_set_query(url: str, params: Dict[str, str]) -> str:
|
||||
url_parts = list(urlparse.urlparse(url))
|
||||
query = dict(urlparse.parse_qsl(url_parts[4]))
|
||||
query.update(params)
|
||||
|
||||
url_parts[4] = urlparse.urlencode(query)
|
||||
return urlparse.urlunparse(url_parts)
|
||||
|
||||
|
||||
def url_get_query(parsed_url: urlparse.ParseResult) -> Dict[str, List[str]]:
|
||||
return urlparse.parse_qs(parsed_url.query)
|
||||
|
||||
|
||||
def clean_youtube_url(url: str) -> Optional[str]:
|
||||
parsed = urlparse.urlparse(url)
|
||||
print(parsed)
|
||||
if (parsed.netloc == "www.youtube.com" or parsed.netloc == "youtube.com") and parsed.path == "/watch":
|
||||
print(url_get_query(parsed))
|
||||
video_id = url_get_query(parsed).get("v", [None])[0]
|
||||
if video_id:
|
||||
return url_set_query("https://www.youtube.com/watch", {"v": video_id})
|
||||
|
||||
elif parsed.netloc == "youtu.be":
|
||||
return url_set_query("https://www.youtube.com/watch", {"v": parsed.path[1:]})
|
||||
|
||||
return None
|
|
@ -5,7 +5,7 @@ BASE_URL = "http://" + SERVER_NAME
|
|||
SECRET_KEY = ""
|
||||
WTF_CSRF_SECRET_KEY = ""
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = "postgres://contentdb:password@db:5432/contentdb"
|
||||
SQLALCHEMY_DATABASE_URI = "postgresql://contentdb:password@db:5432/contentdb"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
GITHUB_CLIENT_ID = ""
|
||||
|
|
|
@ -8,7 +8,7 @@ services:
|
|||
- config.env
|
||||
|
||||
redis:
|
||||
image: 'redis:3.0-alpine'
|
||||
image: 'redis:6.2-alpine'
|
||||
command: redis-server
|
||||
volumes:
|
||||
- './data/redis:/data'
|
||||
|
@ -30,7 +30,7 @@ services:
|
|||
|
||||
worker:
|
||||
build: .
|
||||
command: celery -A app.tasks.celery worker
|
||||
command: celery -A app.tasks.celery worker --concurrency 1
|
||||
env_file:
|
||||
- config.env
|
||||
environment:
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
# Developer Introduction
|
||||
|
||||
## Overview
|
||||
|
||||
ContentDB is a Python [Flask](https://flask.palletsprojects.com/en/2.0.x/) webservice.
|
||||
There's a PostgreSQL database, manipulated using the [SQLAlchemy ORM](https://docs.sqlalchemy.org/en/14/).
|
||||
|
||||
When a user makes a request, Python Flask will direct the request to a *route* in an *blueprint*.
|
||||
A [blueprint](https://flask.palletsprojects.com/en/2.0.x/blueprints/) is a Flask construct to hold a set of routes.
|
||||
Routes are implemented using Python, and likely to respond by using database *models* and rendering HTML *templates*.
|
||||
|
||||
Routes may also use functions in the `app/logic/` module, which is a directory containing reusable functions. This
|
||||
allows the API, background tasks, and the front-end to reuse code.
|
||||
|
||||
To avoid blocking web requests, background tasks run as
|
||||
[Celery](https://docs.celeryproject.org/en/stable/getting-started/introduction.html) tasks.
|
||||
|
||||
|
||||
## Locations
|
||||
|
||||
### The App
|
||||
|
||||
The `app` directory contains the Python Flask application.
|
||||
|
||||
* `blueprints` contains all the Python code behind each endpoint / route.
|
||||
* `templates` contains all the HTML templates used to generate responses. Each directory in here matches a directory in blueprints.
|
||||
* `models` contains all the database table classes. ContentDB uses [SQLAlchemy](https://docs.sqlalchemy.org/en/14/) to interact with PostgreSQL.
|
||||
* `flatpages` contains all the markdown user documentation, including `/help/`.
|
||||
* `public` contains files that should be added to the web server unedited. Examples include CSS libraries, images, and JS scripts.
|
||||
* `scss` contains the stylesheet files, that are compiled into CSS.
|
||||
* `tasks` contains the background tasks executed by [Celery](https://docs.celeryproject.org/en/stable/getting-started/introduction.html).
|
||||
* `logic` is a collection of reusable functions. For example, shared code to create a release or edit a package is here.
|
||||
* `tests` contains the Unit Tests and UI tests.
|
||||
* `utils` contain generic Python utilities, for example common code to manage Flask requests.
|
||||
|
||||
There are also a number of Python files in the `app` directory. The most important one is `querybuilder.py`,
|
||||
which is used to generate SQLAlachemy queries for packages and topics.
|
||||
|
||||
### Supporting directories
|
||||
|
||||
* `migrations` contains code to manage database updates.
|
||||
* `translations` contains user-maintained translations / locales.
|
||||
* `utils` contains bash scripts to aid development and deployment.
|
||||
|
||||
|
||||
## How to find stuff
|
||||
|
||||
Generally, you want to start by finding the endpoint and then seeing the code it calls.
|
||||
|
||||
Endpoints are sensibly organised in `app/blueprints`.
|
||||
|
||||
You can also use a file search. For example, to find the package edit endpoint, search for `"/packages/<author>/<name>/edit/"`.
|
||||
|
||||
|
||||
## Users and Permissions
|
||||
|
||||
Many routes need to check whether a user can do a particular thing. Rather than hard coding this,
|
||||
models tend to have a `checkPerm` function which takes a user and a `Permission`.
|
||||
|
||||
A permission may be something like `Permission.EDIT_PACKAGE` or `Permission.DELETE_THREAD`.
|
||||
|
||||
```bash
|
||||
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
||||
abort(403)
|
||||
```
|
||||
|
||||
|
||||
## Translations
|
||||
|
||||
ContentDB uses [Flask-Babel](https://flask-babel.tkte.ch/) for translation. All strings need to be tagged using
|
||||
a gettext function.
|
||||
|
||||
### Translating templates (HTML)
|
||||
|
||||
```html
|
||||
<div class="something" title="{{ _('This is translatable now') }}">
|
||||
{{ _("Please remember to do something related to this page or something") }}
|
||||
</div>
|
||||
```
|
||||
|
||||
With parameters:
|
||||
|
||||
```html
|
||||
<p>
|
||||
{{ _("Hello %(username)s, you have %(count)d new messages", username=username, count=count) }}
|
||||
</p>
|
||||
```
|
||||
|
||||
See <https://pythonhosted.org/Flask-Babel/#flask.ext.babel.Babel.localeselector> and
|
||||
<https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xiv-i18n-and-l10n>.
|
||||
|
||||
### Translating Python
|
||||
|
||||
If the text is within a request, then you can use gettext like so:
|
||||
|
||||
```py
|
||||
flash(gettext("Some error message"), "danger")
|
||||
```
|
||||
|
||||
If the text is global, for example as part of a python class, then you need to use lazy_gettext:
|
||||
|
||||
```py
|
||||
class PackageForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
|
||||
```
|
|
@ -54,3 +54,5 @@ To hot/live update CDB whilst it is running, use:
|
|||
./utils/reload.sh
|
||||
|
||||
This will only work with python code and templates, it won't update tasks or config.
|
||||
|
||||
Now consider reading the [Developer Introduction](dev_intro.md).
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 011e42c52d21
|
||||
Revises: 6e57b2b4dcdf
|
||||
Create Date: 2022-01-25 18:48:46.367409
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '011e42c52d21'
|
||||
down_revision = '6e57b2b4dcdf'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('package', sa.Column('video_url', sa.String(length=200), nullable=True))
|
||||
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('package', 'video_url')
|
|
@ -0,0 +1,54 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 3710e5fbbe87
|
||||
Revises: f6ef5f35abca
|
||||
Create Date: 2022-01-27 18:50:11.705061
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3710e5fbbe87'
|
||||
down_revision = 'f6ef5f35abca'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
command = """
|
||||
CREATE OR REPLACE FUNCTION parse_websearch(config regconfig, search_query text)
|
||||
RETURNS tsquery AS $$
|
||||
SELECT
|
||||
string_agg(
|
||||
(
|
||||
CASE
|
||||
WHEN position('''' IN words.word) > 0 THEN CONCAT(words.word, ':*')
|
||||
ELSE words.word
|
||||
END
|
||||
),
|
||||
' '
|
||||
)::tsquery
|
||||
FROM (
|
||||
SELECT trim(
|
||||
regexp_split_to_table(
|
||||
websearch_to_tsquery(config, lower(search_query))::text,
|
||||
' '
|
||||
)
|
||||
) AS word
|
||||
) AS words
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION parse_websearch(search_query text)
|
||||
RETURNS tsquery AS $$
|
||||
SELECT parse_websearch('pg_catalog.simple', search_query);
|
||||
$$ LANGUAGE SQL IMMUTABLE;"""
|
||||
|
||||
op.execute(command)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute('DROP FUNCTION public.parse_websearch(regconfig, text);')
|
||||
op.execute('DROP FUNCTION public.parse_websearch(text);')
|
|
@ -0,0 +1,24 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 6e57b2b4dcdf
|
||||
Revises: 17b303f33f68
|
||||
Create Date: 2022-01-22 20:35:25.494712
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6e57b2b4dcdf'
|
||||
down_revision = '17b303f33f68'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('user', sa.Column('locale', sa.String(length=10), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('user', 'locale')
|
|
@ -0,0 +1,34 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: e571b3498f9e
|
||||
Revises: 3710e5fbbe87
|
||||
Create Date: 2022-02-01 19:30:59.537512
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e571b3498f9e'
|
||||
down_revision = '3710e5fbbe87'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table('package_game_support',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('package_id', sa.Integer(), nullable=False),
|
||||
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||
sa.Column('supports', sa.Boolean(), nullable=False),
|
||||
sa.Column('confidence', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['game_id'], ['package.id'], ),
|
||||
sa.ForeignKeyConstraint(['package_id'], ['package.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('game_id', 'package_id', name='_package_game_support_uc')
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('package_game_support')
|
|
@ -0,0 +1,26 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: f6ef5f35abca
|
||||
Revises: 011e42c52d21
|
||||
Create Date: 2022-01-26 00:10:46.610784
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f6ef5f35abca'
|
||||
down_revision = '011e42c52d21'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('package_screenshot', sa.Column('height', sa.Integer(), nullable=False, server_default="0"))
|
||||
op.add_column('package_screenshot', sa.Column('width', sa.Integer(), nullable=False, server_default="0"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('package_screenshot', 'width')
|
||||
op.drop_column('package_screenshot', 'height')
|
|
@ -1,79 +1,82 @@
|
|||
alembic==1.5.3
|
||||
amqp==5.0.5
|
||||
attrs==20.3.0
|
||||
alembic==1.7.5
|
||||
amqp==5.0.9
|
||||
attrs==21.4.0
|
||||
Babel==2.9.1
|
||||
bcrypt==3.2.0
|
||||
beautifulsoup4==4.9.3
|
||||
billiard==3.6.3.0
|
||||
bleach==3.3.0
|
||||
beautifulsoup4==4.10.0
|
||||
billiard==3.6.4.0
|
||||
bleach==4.1.0
|
||||
blinker==1.4
|
||||
celery==5.0.5
|
||||
certifi==2020.12.5
|
||||
chardet==4.0.0
|
||||
click==7.1.2
|
||||
click-didyoumean==0.0.3
|
||||
celery==5.2.3
|
||||
certifi==2021.10.8
|
||||
cffi==1.15.0
|
||||
charset-normalizer==2.0.10
|
||||
click==8.0.3
|
||||
click-didyoumean==0.3.0
|
||||
click-plugins==1.1.1
|
||||
click-repl==0.1.6
|
||||
coverage==5.4
|
||||
decorator==4.4.2
|
||||
dnspython==2.1.0
|
||||
email-validator==1.1.2
|
||||
Flask==1.1.2
|
||||
Flask-Babel==1.0.0
|
||||
Flask-FlatPages==0.7.2
|
||||
click-repl==0.2.0
|
||||
coverage==6.3
|
||||
decorator==5.1.1
|
||||
Deprecated==1.2.13
|
||||
dnspython==2.2.0
|
||||
email-validator==1.1.3
|
||||
Flask==2.0.2
|
||||
Flask-Babel==2.0.0
|
||||
Flask-FlatPages==0.8.1
|
||||
Flask-Gravatar==0.5.0
|
||||
Flask-Login==0.5.0
|
||||
Flask-Mail==0.9.1
|
||||
Flask-Migrate==2.6.0
|
||||
Flask-SQLAlchemy==2.4.4
|
||||
Flask-WTF==0.14.3
|
||||
Flask-Migrate==3.1.0
|
||||
Flask-SQLAlchemy==2.5.1
|
||||
Flask-WTF==1.0.0
|
||||
git-archive-all==1.23.0
|
||||
gitdb==4.0.5
|
||||
gitdb==4.0.9
|
||||
GitHub-Flask==3.2.0
|
||||
GitPython==3.1.12
|
||||
gunicorn==20.0.4
|
||||
importlib-metadata==3.4.0
|
||||
GitPython==3.1.26
|
||||
greenlet==1.1.2
|
||||
gunicorn==20.1.0
|
||||
idna==3.3
|
||||
iniconfig==1.1.1
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.11.3
|
||||
kombu==5.0.2
|
||||
lxml==4.6.3
|
||||
Mako==1.1.4
|
||||
Markdown==3.3.3
|
||||
MarkupSafe==1.1.1
|
||||
packaging==20.9
|
||||
itsdangerous==2.0.1
|
||||
Jinja2==3.0.3
|
||||
kombu==5.2.3
|
||||
libsass==0.21.0
|
||||
lxml==4.7.1
|
||||
Mako==1.1.6
|
||||
Markdown==3.3.6
|
||||
MarkupSafe==2.0.1
|
||||
packaging==21.3
|
||||
passlib==1.7.4
|
||||
Pillow==8.3.2
|
||||
pluggy==0.13.1
|
||||
prompt-toolkit==3.0.14
|
||||
psycopg2==2.8.6
|
||||
py==1.10.0
|
||||
Pygments==2.7.4
|
||||
pyparsing==2.4.7
|
||||
pyScss==1.3.7
|
||||
pytest==6.2.2
|
||||
pytest-cov==2.11.1
|
||||
python-dateutil==2.8.1
|
||||
python-editor==1.0.4
|
||||
pytz==2021.1
|
||||
PyYAML==5.4.1
|
||||
redis==3.5.3
|
||||
requests==2.25.1
|
||||
six==1.15.0
|
||||
smmap==3.0.5
|
||||
soupsieve==2.1
|
||||
SQLAlchemy==1.3.23
|
||||
SQLAlchemy-Searchable==1.2.0
|
||||
SQLAlchemy-Utils==0.36.8
|
||||
Pillow==9.0.0
|
||||
pluggy==1.0.0
|
||||
prompt-toolkit==3.0.26
|
||||
psycopg2==2.9.3
|
||||
py==1.11.0
|
||||
pycparser==2.21
|
||||
Pygments==2.11.2
|
||||
pyparsing==3.0.7
|
||||
pytest==6.2.5
|
||||
pytest-cov==3.0.0
|
||||
pytz==2021.3
|
||||
PyYAML==6.0
|
||||
redis==4.1.2
|
||||
requests==2.27.1
|
||||
six==1.16.0
|
||||
smmap==5.0.0
|
||||
soupsieve==2.3.1
|
||||
SQLAlchemy==1.4.31
|
||||
SQLAlchemy-Searchable==1.4.1
|
||||
SQLAlchemy-Utils==0.38.2
|
||||
toml==0.10.2
|
||||
typing-extensions==3.7.4.3
|
||||
tomli==2.0.0
|
||||
ua-parser==0.10.0
|
||||
urllib3==1.26.5
|
||||
urllib3==1.26.8
|
||||
user-agents==2.2.0
|
||||
validators==0.18.2
|
||||
vine==5.0.0
|
||||
wcwidth==0.2.5
|
||||
webencodings==0.5.1
|
||||
Werkzeug==0.16.1
|
||||
WTForms==2.2.1
|
||||
zipp==3.4.0
|
||||
Werkzeug==2.0.2
|
||||
wrapt==1.13.3
|
||||
WTForms==3.0.1
|
||||
WTForms-SQLAlchemy==0.3
|
||||
|
|
|
@ -24,7 +24,7 @@ GitPython
|
|||
git-archive-all
|
||||
lxml
|
||||
pillow
|
||||
pyScss
|
||||
libsass
|
||||
redis
|
||||
psycopg2
|
||||
|
||||
|
@ -38,8 +38,9 @@ ua-parser
|
|||
user-agents
|
||||
|
||||
Werkzeug
|
||||
WTForms
|
||||
SQLAlchemy
|
||||
WTForms
|
||||
WTForms-SQLAlchemy
|
||||
requests
|
||||
alembic
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -5,7 +5,7 @@ BASE_URL="http://" + SERVER_NAME
|
|||
SECRET_KEY="changeme"
|
||||
WTF_CSRF_SECRET_KEY="changeme"
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = "postgres://contentdb:password@db:5432/contentdb"
|
||||
SQLALCHEMY_DATABASE_URI = "postgresql://contentdb:password@db:5432/contentdb"
|
||||
|
||||
GITHUB_CLIENT_ID = ""
|
||||
GITHUB_CLIENT_SECRET = ""
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
pybabel extract -F babel.cfg -k lazy_gettext -o translations/messages.pot .
|
||||
pybabel update -i translations/messages.pot -d translations
|
||||
pybabel update -i translations/messages.pot -d translations --no-fuzzy-matching
|
||||
|
|
Loading…
Reference in New Issue