aboutsummaryrefslogtreecommitdiff
path: root/app/blueprints
diff options
context:
space:
mode:
authorrubenwardy <rw@rubenwardy.com>2019-11-15 23:51:42 +0000
committerrubenwardy <rw@rubenwardy.com>2019-11-15 23:51:42 +0000
commit64f131ae27a7332245b5a4eb8e1e4879d7d99578 (patch)
treea0b4101ca9c2132a072f1586c0df693583c91cf7 /app/blueprints
parent015abe5a2507ad02273bc89953016c386aae4457 (diff)
downloadcheatdb-64f131ae27a7332245b5a4eb8e1e4879d7d99578.tar.xz
Refactor endpoints to use blueprints instead
Diffstat (limited to 'app/blueprints')
-rw-r--r--app/blueprints/__init__.py10
-rw-r--r--app/blueprints/admin/__init__.py22
-rw-r--r--app/blueprints/admin/admin.py128
-rw-r--r--app/blueprints/admin/licenseseditor.py62
-rw-r--r--app/blueprints/admin/tagseditor.py57
-rw-r--r--app/blueprints/admin/versioneditor.py60
-rw-r--r--app/blueprints/api/__init__.py100
-rw-r--r--app/blueprints/homepage/__init__.py20
-rw-r--r--app/blueprints/metapackages/__init__.py36
-rw-r--r--app/blueprints/notifications/__init__.py34
-rw-r--r--app/blueprints/packages/__init__.py21
-rw-r--r--app/blueprints/packages/editrequests.py173
-rw-r--r--app/blueprints/packages/packages.py372
-rw-r--r--app/blueprints/packages/releases.py219
-rw-r--r--app/blueprints/packages/screenshots.py106
-rw-r--r--app/blueprints/tasks/__init__.py75
-rw-r--r--app/blueprints/threads/__init__.py214
-rw-r--r--app/blueprints/thumbnails/__init__.py74
-rw-r--r--app/blueprints/todo/__init__.py101
-rw-r--r--app/blueprints/users/__init__.py5
-rw-r--r--app/blueprints/users/githublogin.py74
-rw-r--r--app/blueprints/users/profile.py309
22 files changed, 2272 insertions, 0 deletions
diff --git a/app/blueprints/__init__.py b/app/blueprints/__init__.py
new file mode 100644
index 0000000..74aa9ae
--- /dev/null
+++ b/app/blueprints/__init__.py
@@ -0,0 +1,10 @@
+import os, importlib
+
+def create_blueprints(app):
+ dir = os.path.dirname(os.path.realpath(__file__))
+ modules = next(os.walk(dir))[1]
+
+ for modname in modules:
+ if all(c.islower() for c in modname):
+ module = importlib.import_module("." + modname, __name__)
+ app.register_blueprint(module.bp)
diff --git a/app/blueprints/admin/__init__.py b/app/blueprints/admin/__init__.py
new file mode 100644
index 0000000..66eb1ea
--- /dev/null
+++ b/app/blueprints/admin/__init__.py
@@ -0,0 +1,22 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import Blueprint
+
+bp = Blueprint("admin", __name__)
+
+from . import admin, licenseseditor, tagseditor, versioneditor
diff --git a/app/blueprints/admin/admin.py b/app/blueprints/admin/admin.py
new file mode 100644
index 0000000..2a2bace
--- /dev/null
+++ b/app/blueprints/admin/admin.py
@@ -0,0 +1,128 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+import flask_menu as menu
+from . import bp
+from app.models import *
+from celery import uuid
+from app.tasks.importtasks import importRepoScreenshot, importAllDependencies, makeVCSRelease
+from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
+from flask_wtf import FlaskForm
+from wtforms import *
+from app.utils import loginUser, rank_required, triggerNotif
+import datetime
+
+@bp.route("/admin/", methods=["GET", "POST"])
+@rank_required(UserRank.ADMIN)
+def admin_page():
+ if request.method == "POST":
+ action = request.form["action"]
+ if action == "delstuckreleases":
+ PackageRelease.query.filter(PackageRelease.task_id != None).delete()
+ db.session.commit()
+ return redirect(url_for("admin.admin_page"))
+ elif action == "importmodlist":
+ task = importTopicList.delay()
+ return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
+ elif action == "checkusers":
+ task = checkAllForumAccounts.delay()
+ return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
+ elif action == "importscreenshots":
+ packages = Package.query \
+ .filter_by(soft_deleted=False) \
+ .outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \
+ .filter(PackageScreenshot.id==None) \
+ .all()
+ for package in packages:
+ importRepoScreenshot.delay(package.id)
+
+ return redirect(url_for("admin.admin_page"))
+ elif action == "restore":
+ package = Package.query.get(request.form["package"])
+ if package is None:
+ flash("Unknown package", "error")
+ else:
+ package.soft_deleted = False
+ db.session.commit()
+ return redirect(url_for("admin.admin_page"))
+ elif action == "importdepends":
+ task = importAllDependencies.delay()
+ return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
+ elif action == "modprovides":
+ packages = Package.query.filter_by(type=PackageType.MOD).all()
+ mpackage_cache = {}
+ for p in packages:
+ if len(p.provides) == 0:
+ p.provides.append(MetaPackage.GetOrCreate(p.name, mpackage_cache))
+
+ db.session.commit()
+ return redirect(url_for("admin.admin_page"))
+ elif action == "recalcscores":
+ for p in Package.query.all():
+ p.recalcScore()
+
+ db.session.commit()
+ return redirect(url_for("admin.admin_page"))
+ elif action == "vcsrelease":
+ for package in Package.query.filter(Package.repo.isnot(None)).all():
+ if package.releases.count() != 0:
+ continue
+
+ rel = PackageRelease()
+ rel.package = package
+ rel.title = datetime.date.today().isoformat()
+ rel.url = ""
+ rel.task_id = uuid()
+ rel.approved = True
+ db.session.add(rel)
+ db.session.commit()
+
+ makeVCSRelease.apply_async((rel.id, "master"), task_id=rel.task_id)
+
+ msg = "{}: Release {} created".format(package.title, rel.title)
+ triggerNotif(package.author, current_user, msg, rel.getEditURL())
+ db.session.commit()
+
+ else:
+ flash("Unknown action: " + action, "error")
+
+ deleted_packages = Package.query.filter_by(soft_deleted=True).all()
+ return render_template("admin/list.html", deleted_packages=deleted_packages)
+
+class SwitchUserForm(FlaskForm):
+ username = StringField("Username")
+ submit = SubmitField("Switch")
+
+
+@bp.route("/admin/switchuser/", methods=["GET", "POST"])
+@rank_required(UserRank.ADMIN)
+def switch_user():
+ form = SwitchUserForm(formdata=request.form)
+ if request.method == "POST" and form.validate():
+ user = User.query.filter_by(username=form["username"].data).first()
+ if user is None:
+ flash("Unable to find user", "error")
+ elif loginUser(user):
+ return redirect(url_for("users.profile", username=current_user.username))
+ else:
+ flash("Unable to login as user", "error")
+
+
+ # Process GET or invalid POST
+ return render_template("admin/switch_user.html", form=form)
diff --git a/app/blueprints/admin/licenseseditor.py b/app/blueprints/admin/licenseseditor.py
new file mode 100644
index 0000000..c6fca02
--- /dev/null
+++ b/app/blueprints/admin/licenseseditor.py
@@ -0,0 +1,62 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+from . import bp
+from app.models import *
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from app.utils import rank_required
+
+@bp.route("/licenses/")
+@rank_required(UserRank.MODERATOR)
+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")
+ submit = SubmitField("Save")
+
+@bp.route("/licenses/new/", methods=["GET", "POST"])
+@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
+@rank_required(UserRank.MODERATOR)
+def create_edit_license(name=None):
+ license = None
+ if name is not None:
+ license = License.query.filter_by(name=name).first()
+ if license is None:
+ abort(404)
+
+ form = LicenseForm(formdata=request.form, obj=license)
+ if request.method == "GET" and license is None:
+ form.is_foss.data = True
+ elif request.method == "POST" and form.validate():
+ if license is None:
+ license = License(form.name.data)
+ db.session.add(license)
+ flash("Created license " + form.name.data, "success")
+ else:
+ flash("Updated license " + form.name.data, "success")
+
+ form.populate_obj(license)
+ db.session.commit()
+ return redirect(url_for("admin.license_list"))
+
+ return render_template("admin/licenses/edit.html", license=license, form=form)
diff --git a/app/blueprints/admin/tagseditor.py b/app/blueprints/admin/tagseditor.py
new file mode 100644
index 0000000..8fb89f4
--- /dev/null
+++ b/app/blueprints/admin/tagseditor.py
@@ -0,0 +1,57 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+from . import bp
+from app.models import *
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from app.utils import rank_required
+
+@bp.route("/tags/")
+@rank_required(UserRank.MODERATOR)
+def tag_list():
+ return render_template("admin/tags/list.html", tags=Tag.query.order_by(db.asc(Tag.title)).all())
+
+class TagForm(FlaskForm):
+ title = StringField("Title", [InputRequired(), Length(3,100)])
+ name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
+ submit = SubmitField("Save")
+
+@bp.route("/tags/new/", methods=["GET", "POST"])
+@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
+@rank_required(UserRank.MODERATOR)
+def create_edit_tag(name=None):
+ tag = None
+ if name is not None:
+ tag = Tag.query.filter_by(name=name).first()
+ if tag is None:
+ abort(404)
+
+ form = TagForm(formdata=request.form, obj=tag)
+ if request.method == "POST" and form.validate():
+ if tag is None:
+ tag = Tag(form.title.data)
+ db.session.add(tag)
+ else:
+ form.populate_obj(tag)
+ db.session.commit()
+ return redirect(url_for("admin.create_edit_tag", name=tag.name))
+
+ return render_template("admin/tags/edit.html", tag=tag, form=form)
diff --git a/app/blueprints/admin/versioneditor.py b/app/blueprints/admin/versioneditor.py
new file mode 100644
index 0000000..98a9a7c
--- /dev/null
+++ b/app/blueprints/admin/versioneditor.py
@@ -0,0 +1,60 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+from . import bp
+from app.models import *
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from app.utils import rank_required
+
+@bp.route("/versions/")
+@rank_required(UserRank.MODERATOR)
+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)])
+ protocol = IntegerField("Protocol")
+ submit = SubmitField("Save")
+
+@bp.route("/versions/new/", methods=["GET", "POST"])
+@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
+@rank_required(UserRank.MODERATOR)
+def create_edit_version(name=None):
+ version = None
+ if name is not None:
+ version = MinetestRelease.query.filter_by(name=name).first()
+ if version is None:
+ abort(404)
+
+ form = VersionForm(formdata=request.form, obj=version)
+ if request.method == "POST" and form.validate():
+ if version is None:
+ version = MinetestRelease(form.name.data)
+ db.session.add(version)
+ flash("Created version " + form.name.data, "success")
+ else:
+ flash("Updated version " + form.name.data, "success")
+
+ form.populate_obj(version)
+ db.session.commit()
+ return redirect(url_for("admin.version_list"))
+
+ return render_template("admin/versions/edit.html", version=version, form=form)
diff --git a/app/blueprints/api/__init__.py b/app/blueprints/api/__init__.py
new file mode 100644
index 0000000..5092f21
--- /dev/null
+++ b/app/blueprints/api/__init__.py
@@ -0,0 +1,100 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+from app.models import *
+from app.utils import is_package_page
+from app.querybuilder import QueryBuilder
+
+bp = Blueprint("api", __name__)
+
+@bp.route("/api/packages/")
+def packages():
+ qb = QueryBuilder(request.args)
+ query = qb.buildPackageQuery()
+ ver = qb.getMinetestVersion()
+
+ pkgs = [package.getAsDictionaryShort(current_app.config["BASE_URL"], version=ver) \
+ for package in query.all()]
+ return jsonify(pkgs)
+
+
+@bp.route("/api/packages/<author>/<name>/")
+@is_package_page
+def package(package):
+ return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
+
+
+@bp.route("/api/packages/<author>/<name>/dependencies/")
+@is_package_page
+def package_dependencies(package):
+ ret = []
+
+ for dep in package.dependencies:
+ name = None
+ fulfilled_by = None
+
+ if dep.package:
+ name = dep.package.name
+ fulfilled_by = [ dep.package.getAsDictionaryKey() ]
+
+ elif dep.meta_package:
+ name = dep.meta_package.name
+ fulfilled_by = [ pkg.getAsDictionaryKey() for pkg in dep.meta_package.packages]
+
+ else:
+ raise "Malformed dependency"
+
+ ret.append({
+ "name": name,
+ "is_optional": dep.optional,
+ "packages": fulfilled_by
+ })
+
+ return jsonify(ret)
+
+
+@bp.route("/api/topics/")
+def topics():
+ qb = QueryBuilder(request.args)
+ query = qb.buildTopicQuery(show_added=True)
+ return jsonify([t.getAsDictionary() for t in query.all()])
+
+
+@bp.route("/api/topic_discard/", methods=["POST"])
+@login_required
+def topic_set_discard():
+ tid = request.args.get("tid")
+ discard = request.args.get("discard")
+ if tid is None or discard is None:
+ abort(400)
+
+ topic = ForumTopic.query.get(tid)
+ if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
+ abort(403)
+
+ topic.discarded = discard == "true"
+ db.session.commit()
+
+ return jsonify(topic.getAsDictionary())
+
+
+@bp.route("/api/minetest_versions/")
+def versions():
+ return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
+ for rel in MinetestRelease.query.all() if rel.getActual() is not None])
diff --git a/app/blueprints/homepage/__init__.py b/app/blueprints/homepage/__init__.py
new file mode 100644
index 0000000..0d50bbd
--- /dev/null
+++ b/app/blueprints/homepage/__init__.py
@@ -0,0 +1,20 @@
+from flask import Blueprint, render_template
+
+bp = Blueprint("homepage", __name__)
+
+from app.models import *
+import flask_menu as menu
+from sqlalchemy.sql.expression import func
+
+@bp.route("/")
+@menu.register_menu(bp, ".", "Home")
+def home_page():
+ query = Package.query.filter_by(approved=True, soft_deleted=False)
+ count = query.count()
+ new = query.order_by(db.desc(Package.created_at)).limit(8).all()
+ pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
+ pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(4).all()
+ pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(4).all()
+ downloads = db.session.query(func.sum(PackageRelease.downloads)).first()[0]
+ return render_template("index.html", count=count, downloads=downloads, \
+ new=new, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam)
diff --git a/app/blueprints/metapackages/__init__.py b/app/blueprints/metapackages/__init__.py
new file mode 100644
index 0000000..ff54e6d
--- /dev/null
+++ b/app/blueprints/metapackages/__init__.py
@@ -0,0 +1,36 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+
+bp = Blueprint("metapackages", __name__)
+
+from flask_user import *
+from app.models import *
+
+@bp.route("/metapackages/")
+def list_all():
+ mpackages = MetaPackage.query.order_by(db.asc(MetaPackage.name)).all()
+ return render_template("meta/list.html", mpackages=mpackages)
+
+@bp.route("/metapackages/<name>/")
+def view(name):
+ mpackage = MetaPackage.query.filter_by(name=name).first()
+ if mpackage is None:
+ abort(404)
+
+ return render_template("meta/view.html", mpackage=mpackage)
diff --git a/app/blueprints/notifications/__init__.py b/app/blueprints/notifications/__init__.py
new file mode 100644
index 0000000..77263e5
--- /dev/null
+++ b/app/blueprints/notifications/__init__.py
@@ -0,0 +1,34 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import Blueprint
+from flask_user import current_user, login_required
+from app.models import db
+
+bp = Blueprint("notifications", __name__)
+
+@bp.route("/notifications/")
+@login_required
+def list_all():
+ return render_template("notifications/list.html")
+
+@bp.route("/notifications/clear/", methods=["POST"])
+@login_required
+def clear():
+ current_user.notifications.clear()
+ db.session.commit()
+ return redirect(url_for("notifications.list_all"))
diff --git a/app/blueprints/packages/__init__.py b/app/blueprints/packages/__init__.py
new file mode 100644
index 0000000..e4fc4f2
--- /dev/null
+++ b/app/blueprints/packages/__init__.py
@@ -0,0 +1,21 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+from flask import Blueprint
+
+bp = Blueprint("packages", __name__)
+
+from . import packages, screenshots, releases
diff --git a/app/blueprints/packages/editrequests.py b/app/blueprints/packages/editrequests.py
new file mode 100644
index 0000000..5ee9cd1
--- /dev/null
+++ b/app/blueprints/packages/editrequests.py
@@ -0,0 +1,173 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+from flask import *
+from flask_user import *
+from app import app
+from app.models import *
+
+from app.utils import *
+
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
+
+from . import PackageForm
+
+
+class EditRequestForm(PackageForm):
+ edit_title = StringField("Edit Title", [InputRequired(), Length(1, 100)])
+ edit_desc = TextField("Edit Description", [Optional()])
+
+@app.route("/packages/<author>/<name>/requests/new/", methods=["GET","POST"])
+@app.route("/packages/<author>/<name>/requests/<id>/edit/", methods=["GET","POST"])
+@login_required
+@is_package_page
+def create_edit_editrequest_page(package, id=None):
+ edited_package = package
+
+ erequest = None
+ if id is not None:
+ erequest = EditRequest.query.get(id)
+ if erequest.package != package:
+ abort(404)
+
+ if not erequest.checkPerm(current_user, Permission.EDIT_EDITREQUEST):
+ abort(403)
+
+ if erequest.status != 0:
+ flash("Can't edit EditRequest, it has already been merged or rejected", "error")
+ return redirect(erequest.getURL())
+
+ edited_package = Package(package)
+ erequest.applyAll(edited_package)
+
+ form = EditRequestForm(request.form, obj=edited_package)
+ if request.method == "GET":
+ deps = edited_package.dependencies
+ form.harddep_str.data = ",".join([str(x) for x in deps if not x.optional])
+ form.softdep_str.data = ",".join([str(x) for x in deps if x.optional])
+ form.provides_str.data = MetaPackage.ListToSpec(edited_package.provides)
+
+ if request.method == "POST" and form.validate():
+ if erequest is None:
+ erequest = EditRequest()
+ erequest.package = package
+ erequest.author = current_user
+
+ erequest.title = form["edit_title"].data
+ erequest.desc = form["edit_desc"].data
+ db.session.add(erequest)
+
+ EditRequestChange.query.filter_by(request=erequest).delete()
+
+ wasChangeMade = False
+ for e in PackagePropertyKey:
+ newValue = form[e.name].data
+ oldValue = getattr(package, e.name)
+
+ newValueComp = newValue
+ oldValueComp = oldValue
+ if type(newValue) is str:
+ newValue = newValue.replace("\r\n", "\n")
+ newValueComp = newValue.strip()
+ oldValueComp = "" if oldValue is None else oldValue.strip()
+
+ if newValueComp != oldValueComp:
+ change = EditRequestChange()
+ change.request = erequest
+ change.key = e
+ change.oldValue = e.convert(oldValue)
+ change.newValue = e.convert(newValue)
+ db.session.add(change)
+ wasChangeMade = True
+
+ if wasChangeMade:
+ msg = "{}: Edit request #{} {}" \
+ .format(package.title, erequest.id, "created" if id is None else "edited")
+ triggerNotif(package.author, current_user, msg, erequest.getURL())
+ triggerNotif(erequest.author, current_user, msg, erequest.getURL())
+ db.session.commit()
+ return redirect(erequest.getURL())
+ else:
+ flash("No changes detected", "warning")
+ elif erequest is not None:
+ form["edit_title"].data = erequest.title
+ form["edit_desc"].data = erequest.desc
+
+ return render_template("packages/editrequest_create_edit.html", package=package, form=form)
+
+
+@app.route("/packages/<author>/<name>/requests/<id>/")
+@is_package_page
+def view_editrequest_page(package, id):
+ erequest = EditRequest.query.get(id)
+ if erequest is None or erequest.package != package:
+ abort(404)
+
+ clearNotifications(erequest.getURL())
+ return render_template("packages/editrequest_view.html", package=package, request=erequest)
+
+
+@app.route("/packages/<author>/<name>/requests/<id>/approve/", methods=["POST"])
+@is_package_page
+def approve_editrequest_page(package, id):
+ if not package.checkPerm(current_user, Permission.APPROVE_CHANGES):
+ flash("You don't have permission to do that.", "error")
+ return redirect(package.getDetailsURL())
+
+ erequest = EditRequest.query.get(id)
+ if erequest is None or erequest.package != package:
+ abort(404)
+
+ if erequest.status != 0:
+ flash("Edit request has already been resolved", "error")
+
+ else:
+ erequest.status = 1
+ erequest.applyAll(package)
+
+ msg = "{}: Edit request #{} merged".format(package.title, erequest.id)
+ triggerNotif(erequest.author, current_user, msg, erequest.getURL())
+ triggerNotif(package.author, current_user, msg, erequest.getURL())
+ db.session.commit()
+
+ return redirect(package.getDetailsURL())
+
+@app.route("/packages/<author>/<name>/requests/<id>/reject/", methods=["POST"])
+@is_package_page
+def reject_editrequest_page(package, id):
+ if not package.checkPerm(current_user, Permission.APPROVE_CHANGES):
+ flash("You don't have permission to do that.", "error")
+ return redirect(package.getDetailsURL())
+
+ erequest = EditRequest.query.get(id)
+ if erequest is None or erequest.package != package:
+ abort(404)
+
+ if erequest.status != 0:
+ flash("Edit request has already been resolved", "error")
+
+ else:
+ erequest.status = 2
+
+ msg = "{}: Edit request #{} rejected".format(package.title, erequest.id)
+ triggerNotif(erequest.author, current_user, msg, erequest.getURL())
+ triggerNotif(package.author, current_user, msg, erequest.getURL())
+ db.session.commit()
+
+ return redirect(package.getDetailsURL())
diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py
new file mode 100644
index 0000000..1cc2f26
--- /dev/null
+++ b/app/blueprints/packages/packages.py
@@ -0,0 +1,372 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import render_template, abort, request, redirect, url_for, flash
+from flask_user import current_user
+import flask_menu as menu
+
+from . import bp
+
+from app.models import *
+from app.querybuilder import QueryBuilder
+from app.tasks.importtasks import importRepoScreenshot
+from app.utils import *
+
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
+from sqlalchemy import or_
+
+
+@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
+@menu.register_menu(bp, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
+@menu.register_menu(bp, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
+@menu.register_menu(bp, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1' })
+@bp.route("/packages/")
+def list_all():
+ qb = QueryBuilder(request.args)
+ query = qb.buildPackageQuery()
+ title = qb.title
+
+ if qb.lucky:
+ package = query.first()
+ if package:
+ return redirect(package.getDetailsURL())
+
+ topic = qb.buildTopicQuery().first()
+ if qb.search and topic:
+ return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
+
+ page = int(request.args.get("page") or 1)
+ num = min(40, int(request.args.get("n") or 100))
+ query = query.paginate(page, num, True)
+
+ search = request.args.get("q")
+ type_name = request.args.get("type")
+
+ next_url = url_for("packages.list_all", type=type_name, q=search, page=query.next_num) \
+ if query.has_next else None
+ prev_url = url_for("packages.list_all", type=type_name, q=search, page=query.prev_num) \
+ if query.has_prev else None
+
+ topics = None
+ if qb.search and not query.has_next:
+ topics = qb.buildTopicQuery().all()
+
+ tags = Tag.query.all()
+ return render_template("packages/list.html", \
+ title=title, packages=query.items, topics=topics, \
+ query=search, tags=tags, type=type_name, \
+ next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, packages_count=query.total)
+
+
+def getReleases(package):
+ if package.checkPerm(current_user, Permission.MAKE_RELEASE):
+ return package.releases.limit(5)
+ else:
+ return package.releases.filter_by(approved=True).limit(5)
+
+
+@bp.route("/packages/<author>/<name>/")
+@is_package_page
+def view(package):
+ clearNotifications(package.getDetailsURL())
+
+ alternatives = None
+ if package.type == PackageType.MOD:
+ alternatives = Package.query \
+ .filter_by(name=package.name, type=PackageType.MOD, soft_deleted=False) \
+ .filter(Package.id != package.id) \
+ .order_by(db.desc(Package.score)) \
+ .all()
+
+
+ show_similar_topics = current_user == package.author or \
+ package.checkPerm(current_user, Permission.APPROVE_NEW)
+
+ similar_topics = None if not show_similar_topics else \
+ ForumTopic.query \
+ .filter_by(name=package.name) \
+ .filter(ForumTopic.topic_id != package.forums) \
+ .filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
+ .order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
+ .all()
+
+ releases = getReleases(package)
+ requests = [r for r in package.requests if r.status == 0]
+
+ review_thread = package.review_thread
+ if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
+ review_thread = None
+
+ topic_error = None
+ topic_error_lvl = "warning"
+ if not package.approved and package.forums is not None:
+ errors = []
+ if Package.query.filter_by(forums=package.forums, soft_deleted=False).count() > 1:
+ errors.append("<b>Error: Another package already uses this forum topic!</b>")
+ topic_error_lvl = "danger"
+
+ topic = ForumTopic.query.get(package.forums)
+ if topic is not None:
+ if topic.author != package.author:
+ errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
+ topic_error_lvl = "danger"
+
+ if topic.wip:
+ errors.append("Warning: Forum topic is in WIP section, make sure package meets playability standards.")
+ elif package.type != PackageType.TXP:
+ errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
+
+ topic_error = "<br />".join(errors)
+
+
+ threads = Thread.query.filter_by(package_id=package.id)
+ if not current_user.is_authenticated:
+ threads = threads.filter_by(private=False)
+ elif not current_user.rank.atLeast(UserRank.EDITOR) and not current_user == package.author:
+ threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
+
+
+ return render_template("packages/view.html", \
+ package=package, releases=releases, requests=requests, \
+ alternatives=alternatives, similar_topics=similar_topics, \
+ review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl, \
+ threads=threads.all())
+
+
+@bp.route("/packages/<author>/<name>/download/")
+@is_package_page
+def download(package):
+ release = package.getDownloadRelease()
+
+ if release is None:
+ if "application/zip" in request.accept_mimetypes and \
+ not "text/html" in request.accept_mimetypes:
+ return "", 204
+ else:
+ flash("No download available.", "error")
+ return redirect(package.getDetailsURL())
+ else:
+ PackageRelease.query.filter_by(id=release.id).update({
+ "downloads": PackageRelease.downloads + 1
+ })
+ db.session.commit()
+
+ return redirect(release.url, code=302)
+
+
+class PackageForm(FlaskForm):
+ name = StringField("Name (Technical)", [InputRequired(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
+ title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 50)])
+ short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
+ desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
+ type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
+ license = QuerySelectField("License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
+ media_license = QuerySelectField("Media License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
+ provides_str = StringField("Provides (mods included in package)", [Optional()])
+ tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=lambda a: a.title)
+ harddep_str = StringField("Hard Dependencies", [Optional()])
+ softdep_str = StringField("Soft Dependencies", [Optional()])
+ repo = StringField("VCS Repository URL", [Optional(), URL()], filters = [lambda x: x or None])
+ website = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
+ issueTracker = StringField("Issue Tracker URL", [Optional(), URL()], filters = [lambda x: x or None])
+ forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
+ submit = SubmitField("Save")
+
+@bp.route("/packages/new/", methods=["GET", "POST"])
+@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
+@login_required
+def create_edit(author=None, name=None):
+ package = None
+ form = None
+ if author is None:
+ form = PackageForm(formdata=request.form)
+ author = request.args.get("author")
+ if author is None or author == current_user.username:
+ author = current_user
+ else:
+ author = User.query.filter_by(username=author).first()
+ if author is None:
+ flash("Unable to find that user", "error")
+ return redirect(url_for("packages.create_edit"))
+
+ if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
+ flash("Permission denied", "error")
+ return redirect(url_for("packages.create_edit"))
+
+ else:
+ package = getPackageByInfo(author, name)
+ if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
+ return redirect(package.getDetailsURL())
+
+ author = package.author
+
+ form = PackageForm(formdata=request.form, obj=package)
+
+ # 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.forums.data = request.args.get("forums")
+ else:
+ deps = package.dependencies
+ form.harddep_str.data = ",".join([str(x) for x in deps if not x.optional])
+ form.softdep_str.data = ",".join([str(x) for x in deps if x.optional])
+ form.provides_str.data = MetaPackage.ListToSpec(package.provides)
+
+ if request.method == "POST" and form.validate():
+ wasNew = False
+ if not package:
+ package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
+ if package is not None:
+ if package.soft_deleted:
+ Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
+ else:
+ flash("Package already exists!", "error")
+ return redirect(url_for("packages.create_edit"))
+
+ package = Package()
+ package.author = author
+ wasNew = True
+
+ elif package.approved and package.name != form.name.data and \
+ not package.checkPerm(current_user, Permission.CHANGE_NAME):
+ flash("Unable to change package name", "danger")
+ return redirect(url_for("packages.create_edit", author=author, name=name))
+
+ else:
+ triggerNotif(package.author, current_user,
+ "{} edited".format(package.title), package.getDetailsURL())
+
+ form.populate_obj(package) # copy to row
+
+ if package.type== PackageType.TXP:
+ package.license = package.media_license
+
+ mpackage_cache = {}
+ package.provides.clear()
+ mpackages = MetaPackage.SpecToList(form.provides_str.data, mpackage_cache)
+ for m in mpackages:
+ package.provides.append(m)
+
+ Dependency.query.filter_by(depender=package).delete()
+ deps = Dependency.SpecToList(package, form.harddep_str.data, mpackage_cache)
+ for dep in deps:
+ dep.optional = False
+ db.session.add(dep)
+
+ deps = Dependency.SpecToList(package, form.softdep_str.data, mpackage_cache)
+ for dep in deps:
+ dep.optional = True
+ db.session.add(dep)
+
+ if wasNew and package.type == PackageType.MOD and not package.name in mpackage_cache:
+ m = MetaPackage.GetOrCreate(package.name, mpackage_cache)
+ package.provides.append(m)
+
+ package.tags.clear()
+ for tag in form.tags.raw_data:
+ package.tags.append(Tag.query.get(tag))
+
+ db.session.commit() # save
+
+ next_url = package.getDetailsURL()
+ if wasNew and package.repo is not None:
+ task = importRepoScreenshot.delay(package.id)
+ next_url = url_for("tasks.check", id=task.id, r=next_url)
+
+ if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
+ next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
+
+ return redirect(next_url)
+
+ package_query = Package.query.filter_by(approved=True, soft_deleted=False)
+ if package is not None:
+ package_query = package_query.filter(Package.id != package.id)
+
+ enableWizard = name is None and request.method != "POST"
+ return render_template("packages/create_edit.html", package=package, \
+ form=form, author=author, enable_wizard=enableWizard, \
+ packages=package_query.all(), \
+ mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all())
+
+@bp.route("/packages/<author>/<name>/approve/", methods=["POST"])
+@login_required
+@is_package_page
+def approve(package):
+ if not package.checkPerm(current_user, Permission.APPROVE_NEW):
+ flash("You don't have permission to do that.", "error")
+
+ elif package.approved:
+ flash("Package has already been approved", "error")
+
+ else:
+ package.approved = True
+
+ screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
+ for s in screenshots:
+ s.approved = True
+
+ triggerNotif(package.author, current_user,
+ "{} approved".format(package.title), package.getDetailsURL())
+ db.session.commit()
+
+ return redirect(package.getDetailsURL())
+
+
+@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
+@login_required
+@is_package_page
+def remove(package):
+ if request.method == "GET":
+ return render_template("packages/remove.html", package=package)
+
+ if "delete" in request.form:
+ if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
+ flash("You don't have permission to do that.", "error")
+ return redirect(package.getDetailsURL())
+
+ package.soft_deleted = True
+
+ url = url_for("users.profile", username=package.author.username)
+ triggerNotif(package.author, current_user,
+ "{} deleted".format(package.title), url)
+ db.session.commit()
+
+ flash("Deleted package", "success")
+
+ return redirect(url)
+ elif "unapprove" in request.form:
+ if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
+ flash("You don't have permission to do that.", "error")
+ return redirect(package.getDetailsURL())
+
+ package.approved = False
+
+ triggerNotif(package.author, current_user,
+ "{} deleted".format(package.title), package.getDetailsURL())
+ db.session.commit()
+
+ flash("Unapproved package", "success")
+
+ return redirect(package.getDetailsURL())
+ else:
+ abort(400)
diff --git a/app/blueprints/packages/releases.py b/app/blueprints/packages/releases.py
new file mode 100644
index 0000000..89a9a00
--- /dev/null
+++ b/app/blueprints/packages/releases.py
@@ -0,0 +1,219 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+
+from . import bp
+
+from app.models import *
+from app.tasks.importtasks import makeVCSRelease
+from app.utils import *
+
+from celery import uuid
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from wtforms.ext.sqlalchemy.fields import QuerySelectField
+
+
+def get_mt_releases(is_max):
+ query = MinetestRelease.query.order_by(db.asc(MinetestRelease.id))
+ if is_max:
+ query = query.limit(query.count() - 1)
+ else:
+ query = query.filter(MinetestRelease.name != "0.4.17")
+
+ return query
+
+
+class CreatePackageReleaseForm(FlaskForm):
+ title = StringField("Title", [InputRequired(), Length(1, 30)])
+ uploadOpt = RadioField ("Method", choices=[("upload", "File Upload")], default="upload")
+ vcsLabel = StringField("VCS Commit Hash, Branch, or Tag", default="master")
+ fileUpload = FileField("File Upload")
+ min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
+ query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
+ max_rel = QuerySelectField("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("Save")
+
+class EditPackageReleaseForm(FlaskForm):
+ title = StringField("Title", [InputRequired(), Length(1, 30)])
+ url = StringField("URL", [URL])
+ task_id = StringField("Task ID", filters = [lambda x: x or None])
+ approved = BooleanField("Is Approved")
+ min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
+ query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
+ max_rel = QuerySelectField("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("Save")
+
+@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
+@login_required
+@is_package_page
+def create_release(package):
+ if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
+ return redirect(package.getDetailsURL())
+
+ # Initial form class from post data and default data
+ form = CreatePackageReleaseForm()
+ if package.repo is not None:
+ form["uploadOpt"].choices = [("vcs", "From Git Commit or Branch"), ("upload", "File Upload")]
+ if request.method != "POST":
+ form["uploadOpt"].data = "vcs"
+
+ if request.method == "POST" and form.validate():
+ if form["uploadOpt"].data == "vcs":
+ rel = PackageRelease()
+ rel.package = package
+ rel.title = form["title"].data
+ rel.url = ""
+ rel.task_id = uuid()
+ rel.min_rel = form["min_rel"].data.getActual()
+ rel.max_rel = form["max_rel"].data.getActual()
+ db.session.add(rel)
+ db.session.commit()
+
+ makeVCSRelease.apply_async((rel.id, form["vcsLabel"].data), task_id=rel.task_id)
+
+ msg = "{}: Release {} created".format(package.title, rel.title)
+ triggerNotif(package.author, current_user, msg, rel.getEditURL())
+ db.session.commit()
+
+ return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
+ else:
+ uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file")
+ if uploadedPath is not None:
+ rel = PackageRelease()
+ rel.package = package
+ rel.title = form["title"].data
+ rel.url = uploadedPath
+ rel.min_rel = form["min_rel"].data.getActual()
+ rel.max_rel = form["max_rel"].data.getActual()
+ rel.approve(current_user)
+ db.session.add(rel)
+ db.session.commit()
+
+ msg = "{}: Release {} created".format(package.title, rel.title)
+ triggerNotif(package.author, current_user, msg, rel.getEditURL())
+ db.session.commit()
+ return redirect(package.getDetailsURL())
+
+ return render_template("packages/release_new.html", package=package, form=form)
+
+@bp.route("/packages/<author>/<name>/releases/<id>/download/")
+@is_package_page
+def download_release(package, id):
+ release = PackageRelease.query.get(id)
+ if release is None or release.package != package:
+ abort(404)
+
+ if release is None:
+ if "application/zip" in request.accept_mimetypes and \
+ not "text/html" in request.accept_mimetypes:
+ return "", 204
+ else:
+ flash("No download available.", "error")
+ return redirect(package.getDetailsURL())
+ else:
+ PackageRelease.query.filter_by(id=release.id).update({
+ "downloads": PackageRelease.downloads + 1
+ })
+ db.session.commit()
+
+ return redirect(release.url, code=300)
+
+@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
+@login_required
+@is_package_page
+def edit_release(package, id):
+ release = PackageRelease.query.get(id)
+ if release is None or release.package != package:
+ abort(404)
+
+ clearNotifications(release.getEditURL())
+
+ canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
+ canApprove = package.checkPerm(current_user, Permission.APPROVE_RELEASE)
+ if not (canEdit or canApprove):
+ return redirect(package.getDetailsURL())
+
+ # Initial form class from post data and default data
+ form = EditPackageReleaseForm(formdata=request.form, obj=release)
+ if request.method == "POST" and form.validate():
+ wasApproved = release.approved
+ if canEdit:
+ release.title = form["title"].data
+ release.min_rel = form["min_rel"].data.getActual()
+ release.max_rel = form["max_rel"].data.getActual()
+
+ if package.checkPerm(current_user, Permission.CHANGE_RELEASE_URL):
+ release.url = form["url"].data
+ release.task_id = form["task_id"].data
+ if release.task_id is not None:
+ release.task_id = None
+
+ if canApprove:
+ release.approved = form["approved"].data
+ else:
+ release.approved = wasApproved
+
+ db.session.commit()
+ return redirect(package.getDetailsURL())
+
+ return render_template("packages/release_edit.html", package=package, release=release, form=form)
+
+
+
+class BulkReleaseForm(FlaskForm):
+ set_min = BooleanField("Set Min")
+ min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
+ query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
+ set_max = BooleanField("Set Max")
+ max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
+ query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
+ only_change_none = BooleanField("Only change values previously set as none")
+ submit = SubmitField("Update")
+
+
+@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
+@login_required
+@is_package_page
+def bulk_change_release(package):
+ if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
+ return redirect(package.getDetailsURL())
+
+ # Initial form class from post data and default data
+ form = BulkReleaseForm()
+
+ if request.method == "GET":
+ form.only_change_none.data = True
+ elif request.method == "POST" and form.validate():
+ only_change_none = form.only_change_none.data
+
+ for release in package.releases.all():
+ if form["set_min"].data and (not only_change_none or release.min_rel is None):
+ release.min_rel = form["min_rel"].data.getActual()
+ if form["set_max"].data and (not only_change_none or release.max_rel is None):
+ release.max_rel = form["max_rel"].data.getActual()
+
+ db.session.commit()
+
+ return redirect(package.getDetailsURL())
+
+ return render_template("packages/release_bulk_change.html", package=package, form=form)
diff --git a/app/blueprints/packages/screenshots.py b/app/blueprints/packages/screenshots.py
new file mode 100644
index 0000000..c7fc7eb
--- /dev/null
+++ b/app/blueprints/packages/screenshots.py
@@ -0,0 +1,106 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+
+from . import bp
+
+from app.models import *
+from app.utils import *
+
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+
+
+class CreateScreenshotForm(FlaskForm):
+ title = StringField("Title/Caption", [Optional()])
+ fileUpload = FileField("File Upload", [InputRequired()])
+ submit = SubmitField("Save")
+
+
+class EditScreenshotForm(FlaskForm):
+ title = StringField("Title/Caption", [Optional()])
+ approved = BooleanField("Is Approved")
+ delete = BooleanField("Delete")
+ submit = SubmitField("Save")
+
+@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
+@login_required
+@is_package_page
+def create_screenshot(package, id=None):
+ if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
+ return redirect(package.getDetailsURL())
+
+ # Initial form class from post data and default data
+ form = CreateScreenshotForm()
+ if request.method == "POST" and form.validate():
+ uploadedPath = doFileUpload(form.fileUpload.data, "image",
+ "a PNG or JPG image file")
+ if uploadedPath is not None:
+ ss = PackageScreenshot()
+ ss.package = package
+ ss.title = form["title"].data or "Untitled"
+ ss.url = uploadedPath
+ ss.approved = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
+ db.session.add(ss)
+
+ msg = "{}: Screenshot added {}" \
+ .format(package.title, ss.title)
+ triggerNotif(package.author, current_user, msg, package.getDetailsURL())
+ db.session.commit()
+ return redirect(package.getDetailsURL())
+
+ return render_template("packages/screenshot_new.html", package=package, form=form)
+
+@bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
+@login_required
+@is_package_page
+def edit_screenshot(package, id):
+ screenshot = PackageScreenshot.query.get(id)
+ if screenshot is None or screenshot.package != package:
+ abort(404)
+
+ canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
+ canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
+ if not (canEdit or canApprove):
+ return redirect(package.getDetailsURL())
+
+ clearNotifications(screenshot.getEditURL())
+
+ # Initial form class from post data and default data
+ form = EditScreenshotForm(formdata=request.form, obj=screenshot)
+ if request.method == "POST" and form.validate():
+ if canEdit and form["delete"].data:
+ PackageScreenshot.query.filter_by(id=id).delete()
+
+ else:
+ wasApproved = screenshot.approved
+
+ if canEdit:
+ screenshot.title = form["title"].data or "Untitled"
+
+ if canApprove:
+ screenshot.approved = form["approved"].data
+ else:
+ screenshot.approved = wasApproved
+
+ db.session.commit()
+ return redirect(package.getDetailsURL())
+
+ return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
diff --git a/app/blueprints/tasks/__init__.py b/app/blueprints/tasks/__init__.py
new file mode 100644
index 0000000..8d002db
--- /dev/null
+++ b/app/blueprints/tasks/__init__.py
@@ -0,0 +1,75 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+import flask_menu as menu
+from app import csrf
+from app.models import *
+from app.tasks import celery, TaskError
+from app.tasks.importtasks import getMeta
+from app.utils import shouldReturnJson
+from app.utils import *
+
+bp = Blueprint("tasks", __name__)
+
+@csrf.exempt
+@bp.route("/tasks/getmeta/new/", methods=["POST"])
+@login_required
+def start_getmeta():
+ author = request.args.get("author")
+ author = current_user.forums_username if author is None else author
+ aresult = getMeta.delay(request.args.get("url"), author)
+ return jsonify({
+ "poll_url": url_for("tasks.check", id=aresult.id),
+ })
+
+@bp.route("/tasks/<id>/")
+def check(id):
+ result = celery.AsyncResult(id)
+ status = result.status
+ traceback = result.traceback
+ result = result.result
+
+ info = None
+ if isinstance(result, Exception):
+ info = {
+ 'id': id,
+ 'status': status,
+ }
+
+ if current_user.is_authenticated and current_user.rank.atLeast(UserRank.ADMIN):
+ info["error"] = str(traceback)
+ elif str(result)[1:12] == "TaskError: ":
+ info["error"] = str(result)[12:-1]
+ else:
+ info["error"] = "Unknown server error"
+ else:
+ info = {
+ 'id': id,
+ 'status': status,
+ 'result': result,
+ }
+
+ if shouldReturnJson():
+ return jsonify(info)
+ else:
+ r = request.args.get("r")
+ if r is not None and status == "SUCCESS":
+ return redirect(r)
+ else:
+ return render_template("tasks/view.html", info=info)
diff --git a/app/blueprints/threads/__init__.py b/app/blueprints/threads/__init__.py
new file mode 100644
index 0000000..0eee201
--- /dev/null
+++ b/app/blueprints/threads/__init__.py
@@ -0,0 +1,214 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+
+bp = Blueprint("threads", __name__)
+
+from flask_user import *
+from app.models import *
+from app.utils import triggerNotif, clearNotifications
+
+import datetime
+
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+
+@bp.route("/threads/")
+def list_all():
+ query = Thread.query
+ if not Permission.SEE_THREAD.check(current_user):
+ query = query.filter_by(private=False)
+ return render_template("threads/list.html", threads=query.all())
+
+
+@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
+@login_required
+def subscribe(id):
+ thread = Thread.query.get(id)
+ if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
+ abort(404)
+
+ if current_user in thread.watchers:
+ flash("Already subscribed!", "success")
+ else:
+ flash("Subscribed to thread", "success")
+ thread.watchers.append(current_user)
+ db.session.commit()
+
+ return redirect(url_for("threads.view", id=id))
+
+
+@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
+@login_required
+def unsubscribe(id):
+ thread = Thread.query.get(id)
+ if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
+ abort(404)
+
+ if current_user in thread.watchers:
+ flash("Unsubscribed!", "success")
+ thread.watchers.remove(current_user)
+ db.session.commit()
+ else:
+ flash("Not subscribed to thread", "success")
+
+ return redirect(url_for("threads.view", id=id))
+
+
+@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
+def view(id):
+ clearNotifications(url_for("threads.view", id=id))
+
+ thread = Thread.query.get(id)
+ if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
+ abort(404)
+
+ if current_user.is_authenticated and request.method == "POST":
+ comment = request.form["comment"]
+
+ if not current_user.canCommentRL():
+ flash("Please wait before commenting again", "danger")
+ if package:
+ return redirect(package.getDetailsURL())
+ else:
+ return redirect(url_for("home_page"))
+
+ if len(comment) <= 500 and len(comment) > 3:
+ reply = ThreadReply()
+ reply.author = current_user
+ reply.comment = comment
+ db.session.add(reply)
+
+ thread.replies.append(reply)
+ if not current_user in thread.watchers:
+ thread.watchers.append(current_user)
+
+ msg = None
+ if thread.package is None:
+ msg = "New comment on '{}'".format(thread.title)
+ else:
+ msg = "New comment on '{}' on package {}".format(thread.title, thread.package.title)
+
+
+ for user in thread.watchers:
+ if user != current_user:
+ triggerNotif(user, current_user, msg, url_for("threads.view", id=thread.id))
+
+ db.session.commit()
+
+ return redirect(url_for("threads.view", id=id))
+
+ else:
+ flash("Comment needs to be between 3 and 500 characters.")
+
+ return render_template("threads/view.html", thread=thread)
+
+
+class ThreadForm(FlaskForm):
+ title = StringField("Title", [InputRequired(), Length(3,100)])
+ comment = TextAreaField("Comment", [InputRequired(), Length(10, 500)])
+ private = BooleanField("Private")
+ submit = SubmitField("Open Thread")
+
+@bp.route("/threads/new/", methods=["GET", "POST"])
+@login_required
+def new():
+ form = ThreadForm(formdata=request.form)
+
+ package = None
+ if "pid" in request.args:
+ package = Package.query.get(int(request.args.get("pid")))
+ if package is None:
+ flash("Unable to find that package!", "error")
+
+ # Don't allow making orphan threads on approved packages for now
+ if package is None:
+ abort(403)
+
+ def_is_private = request.args.get("private") or False
+ if package is None:
+ def_is_private = True
+ allow_change = package and package.approved
+ is_review_thread = package and not package.approved
+
+ # Check that user can make the thread
+ if not package.checkPerm(current_user, Permission.CREATE_THREAD):
+ flash("Unable to create thread!", "error")
+ return redirect(url_for("home_page"))
+
+ # Only allow creating one thread when not approved
+ elif is_review_thread and package.review_thread is not None:
+ flash("A review thread already exists!", "error")
+ return redirect(url_for("threads.view", id=package.review_thread.id))
+
+ elif not current_user.canOpenThreadRL():
+ flash("Please wait before opening another thread", "danger")
+
+ if package:
+ return redirect(package.getDetailsURL())
+ else:
+ return redirect(url_for("home_page"))
+
+ # Set default values
+ elif request.method == "GET":
+ form.private.data = def_is_private
+ form.title.data = request.args.get("title") or ""
+
+ # Validate and submit
+ elif request.method == "POST" and form.validate():
+ thread = Thread()
+ thread.author = current_user
+ thread.title = form.title.data
+ thread.private = form.private.data if allow_change else def_is_private
+ thread.package = package
+ db.session.add(thread)
+
+ thread.watchers.append(current_user)
+ if package is not None and package.author != current_user:
+ thread.watchers.append(package.author)
+
+ reply = ThreadReply()
+ reply.thread = thread
+ reply.author = current_user
+ reply.comment = form.comment.data
+ db.session.add(reply)
+
+ thread.replies.append(reply)
+
+ db.session.commit()
+
+ if is_review_thread:
+ package.review_thread = thread
+
+ notif_msg = None
+ if package is not None:
+ notif_msg = "New thread '{}' on package {}".format(thread.title, package.title)
+ triggerNotif(package.author, current_user, notif_msg, url_for("threads.view", id=thread.id))
+ else:
+ notif_msg = "New thread '{}'".format(thread.title)
+
+ for user in User.query.filter(User.rank >= UserRank.EDITOR).all():
+ triggerNotif(user, current_user, notif_msg, url_for("threads.view", id=thread.id))
+
+ db.session.commit()
+
+ return redirect(url_for("threads.view", id=thread.id))
+
+
+ return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
diff --git a/app/blueprints/thumbnails/__init__.py b/app/blueprints/thumbnails/__init__.py
new file mode 100644
index 0000000..1f46102
--- /dev/null
+++ b/app/blueprints/thumbnails/__init__.py
@@ -0,0 +1,74 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+
+bp = Blueprint("thumbnails", __name__)
+
+import os
+from PIL import Image
+
+ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233)]
+
+def mkdir(path):
+ if not os.path.isdir(path):
+ os.mkdir(path)
+
+mkdir("app/public/thumbnails/")
+
+def resize_and_crop(img_path, modified_path, size):
+ img = Image.open(img_path)
+
+ # Get current and desired ratio for the images
+ img_ratio = img.size[0] / float(img.size[1])
+ ratio = size[0] / float(size[1])
+
+ # Is more portrait than target, scale and crop
+ if ratio > img_ratio:
+ img = img.resize((int(size[0]), int(size[0] * img.size[1] / img.size[0])),
+ Image.BICUBIC)
+ box = (0, (img.size[1] - size[1]) / 2, img.size[0], (img.size[1] + size[1]) / 2)
+ img = img.crop(box)
+
+ # Is more landscape than target, scale and crop
+ elif ratio < img_ratio:
+ img = img.resize((int(size[1] * img.size[0] / img.size[1]), int(size[1])),
+ Image.BICUBIC)
+ box = ((img.size[0] - size[0]) / 2, 0, (img.size[0] + size[0]) / 2, img.size[1])
+ img = img.crop(box)
+
+ # Is exactly the same ratio as target
+ else:
+ img = img.resize(size, Image.BICUBIC)
+
+ img.save(modified_path)
+
+
+@bp.route("/thumbnails/<int:level>/<img>")
+def make_thumbnail(img, level):
+ if level > len(ALLOWED_RESOLUTIONS) or level <= 0:
+ abort(403)
+
+ w, h = ALLOWED_RESOLUTIONS[level - 1]
+
+ mkdir("app/public/thumbnails/{:d}/".format(level))
+
+ cache_filepath = "public/thumbnails/{:d}/{}".format(level, img)
+ source_filepath = "public/uploads/" + img
+
+ resize_and_crop("app/" + source_filepath, "app/" + cache_filepath, (w, h))
+ return send_file(cache_filepath)
diff --git a/app/blueprints/todo/__init__.py b/app/blueprints/todo/__init__.py
new file mode 100644
index 0000000..f4f818a
--- /dev/null
+++ b/app/blueprints/todo/__init__.py
@@ -0,0 +1,101 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+from flask import *
+from flask_user import *
+import flask_menu as menu
+from app.models import *
+from app.querybuilder import QueryBuilder
+
+bp = Blueprint("todo", __name__)
+
+@bp.route("/todo/", methods=["GET", "POST"])
+@login_required
+def view():
+ canApproveNew = Permission.APPROVE_NEW.check(current_user)
+ canApproveRel = Permission.APPROVE_RELEASE.check(current_user)
+ canApproveScn = Permission.APPROVE_SCREENSHOT.check(current_user)
+
+ packages = None
+ if canApproveNew:
+ packages = Package.query.filter_by(approved=False, soft_deleted=False).order_by(db.desc(Package.created_at)).all()
+
+ releases = None
+ if canApproveRel:
+ releases = PackageRelease.query.filter_by(approved=False).all()
+
+ screenshots = None
+ if canApproveScn:
+ screenshots = PackageScreenshot.query.filter_by(approved=False).all()
+
+ if not canApproveNew and not canApproveRel and not canApproveScn:
+ abort(403)
+
+ if request.method == "POST":
+ if request.form["action"] == "screenshots_approve_all":
+ if not canApproveScn:
+ abort(403)
+
+ PackageScreenshot.query.update({ "approved": True })
+ db.session.commit()
+ return redirect(url_for("todo.view"))
+ else:
+ abort(400)
+
+ topic_query = ForumTopic.query \
+ .filter_by(discarded=False)
+
+ total_topics = topic_query.count()
+ topics_to_add = topic_query \
+ .filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
+ .count()
+
+ return render_template("todo/list.html", title="Reports and Work Queue",
+ packages=packages, releases=releases, screenshots=screenshots,
+ canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
+ topics_to_add=topics_to_add, total_topics=total_topics)
+
+
+@bp.route("/todo/topics/")
+@login_required
+def topics():
+ qb = QueryBuilder(request.args)
+ qb.setSortIfNone("date")
+ query = qb.buildTopicQuery()
+
+ tmp_q = ForumTopic.query
+ if not qb.show_discarded:
+ tmp_q = tmp_q.filter_by(discarded=False)
+ total = tmp_q.count()
+ topic_count = query.count()
+
+ page = int(request.args.get("page") or 1)
+ num = int(request.args.get("n") or 100)
+ if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
+ num = 100
+
+ query = query.paginate(page, num, True)
+ next_url = url_for("todo.topics", page=query.next_num, query=qb.search, \
+ show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
+ if query.has_next else None
+ prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search, \
+ show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
+ if query.has_prev else None
+
+ return render_template("todo/topics.html", topics=query.items, total=total, \
+ topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded, \
+ next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, \
+ n=num, sort_by=qb.order_by)
diff --git a/app/blueprints/users/__init__.py b/app/blueprints/users/__init__.py
new file mode 100644
index 0000000..98cf34a
--- /dev/null
+++ b/app/blueprints/users/__init__.py
@@ -0,0 +1,5 @@
+from flask import Blueprint
+
+bp = Blueprint("users", __name__)
+
+from . import githublogin, profile
diff --git a/app/blueprints/users/githublogin.py b/app/blueprints/users/githublogin.py
new file mode 100644
index 0000000..458c637
--- /dev/null
+++ b/app/blueprints/users/githublogin.py
@@ -0,0 +1,74 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+from flask_login import login_user, logout_user
+from sqlalchemy import func
+import flask_menu as menu
+from flask_github import GitHub
+from . import bp
+from app import github
+from app.models import *
+from app.utils import loginUser
+
+@bp.route("/user/github/start/")
+def github_signin():
+ return github.authorize("")
+
+@bp.route("/user/github/callback/")
+@github.authorized_handler
+def github_authorized(oauth_token):
+ next_url = request.args.get("next")
+ if oauth_token is None:
+ flash("Authorization failed [err=gh-oauth-login-failed]", "danger")
+ return redirect(url_for("user.login"))
+
+ import requests
+
+ # Get Github username
+ url = "https://api.github.com/user"
+ r = requests.get(url, headers={"Authorization": "token " + oauth_token})
+ username = r.json()["login"]
+
+ # Get user by github username
+ userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
+
+ # If logged in, connect
+ if current_user and current_user.is_authenticated:
+ if userByGithub is None:
+ current_user.github_username = username
+ db.session.commit()
+ flash("Linked github to account", "success")
+ return redirect(url_for("home_page"))
+ else:
+ flash("Github account is already associated with another user", "danger")
+ return redirect(url_for("home_page"))
+
+ # If not logged in, log in
+ else:
+ if userByGithub is None:
+ flash("Unable to find an account for that Github user", "error")
+ return redirect(url_for("users.claim"))
+ elif loginUser(userByGithub):
+ if current_user.password is None:
+ return redirect(next_url or url_for("users.set_password", optional=True))
+ else:
+ return redirect(next_url or url_for("home_page"))
+ else:
+ flash("Authorization failed [err=gh-login-failed]", "danger")
+ return redirect(url_for("user.login"))
diff --git a/app/blueprints/users/profile.py b/app/blueprints/users/profile.py
new file mode 100644
index 0000000..fd8d7d9
--- /dev/null
+++ b/app/blueprints/users/profile.py
@@ -0,0 +1,309 @@
+# Content DB
+# Copyright (C) 2018 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+from flask import *
+from flask_user import *
+from flask_login import login_user, logout_user
+from app import markdown
+from . import bp
+from app.models import *
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from app.utils import randomString, loginUser, rank_required
+from app.tasks.forumtasks import checkForumAccount
+from app.tasks.emails import sendVerifyEmail, sendEmailRaw
+from app.tasks.phpbbparser import getProfile
+
+# Define the User profile form
+class UserProfileForm(FlaskForm):
+ display_name = StringField("Display name", [Optional(), Length(2, 20)])
+ email = StringField("Email", [Optional(), Email()], filters = [lambda x: x or None])
+ website_url = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
+ donate_url = StringField("Donation URL", [Optional(), URL()], filters = [lambda x: x or None])
+ rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce, default=UserRank.NEW_MEMBER)
+ submit = SubmitField("Save")
+
+
+@bp.route("/users/", methods=["GET"])
+def list_all():
+ users = User.query.order_by(db.desc(User.rank), db.asc(User.display_name)).all()
+ return render_template("users/list.html", users=users)
+
+
+@bp.route("/users/<username>/", methods=["GET", "POST"])
+def profile(username):
+ user = User.query.filter_by(username=username).first()
+ if not user:
+ abort(404)
+
+ form = None
+ if user.checkPerm(current_user, Permission.CHANGE_DNAME) or \
+ user.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
+ user.checkPerm(current_user, Permission.CHANGE_RANK):
+ # Initialize form
+ form = UserProfileForm(formdata=request.form, obj=user)
+
+ # Process valid POST
+ if request.method=="POST" and form.validate():
+ # Copy form fields to user_profile fields
+ if user.checkPerm(current_user, Permission.CHANGE_DNAME):
+ user.display_name = form["display_name"].data
+ user.website_url = form["website_url"].data
+ user.donate_url = form["donate_url"].data
+
+ if user.checkPerm(current_user, Permission.CHANGE_RANK):
+ newRank = form["rank"].data
+ if current_user.rank.atLeast(newRank):
+ user.rank = form["rank"].data
+ else:
+ flash("Can't promote a user to a rank higher than yourself!", "error")
+
+ if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
+ newEmail = form["email"].data
+ if newEmail != user.email and newEmail.strip() != "":
+ token = randomString(32)
+
+ ver = UserEmailVerification()
+ ver.user = user
+ ver.token = token
+ ver.email = newEmail
+ db.session.add(ver)
+ db.session.commit()
+
+ task = sendVerifyEmail.delay(newEmail, token)
+ return redirect(url_for("tasks.check", id=task.id, r=url_for("users.profile", username=username)))
+
+ # Save user_profile
+ db.session.commit()
+
+ # Redirect to home page
+ return redirect(url_for("users.profile", username=username))
+
+ packages = user.packages.filter_by(soft_deleted=False)
+ if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
+ packages = packages.filter_by(approved=True)
+ packages = packages.order_by(db.asc(Package.title))
+
+ topics_to_add = None
+ if current_user == user or user.checkPerm(current_user, Permission.CHANGE_AUTHOR):
+ topics_to_add = ForumTopic.query \
+ .filter_by(author_id=user.id) \
+ .filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
+ .order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
+ .all()
+
+ # Process GET or invalid POST
+ return render_template("users/users.profile.html",
+ user=user, form=form, packages=packages, topics_to_add=topics_to_add)
+
+
+@bp.route("/users/<username>/check/", methods=["POST"])
+@login_required
+def user_check(username):
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ abort(404)
+
+ if current_user != user and not current_user.rank.atLeast(UserRank.MODERATOR):
+ abort(403)
+
+ if user.forums_username is None:
+ abort(404)
+
+ task = checkForumAccount.delay(user.forums_username)
+ next_url = url_for("users.profile", username=username)
+
+ return redirect(url_for("tasks.check", id=task.id, r=next_url))
+
+
+class SendEmailForm(FlaskForm):
+ subject = StringField("Subject", [InputRequired(), Length(1, 300)])
+ text = TextAreaField("Message", [InputRequired()])
+ submit = SubmitField("Send")
+
+
+@bp.route("/users/<username>/email/", methods=["GET", "POST"])
+@rank_required(UserRank.MODERATOR)
+def send_email(username):
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ abort(404)
+
+ next_url = url_for("users.profile", username=user.username)
+
+ if user.email is None:
+ flash("User has no email address!", "error")
+ return redirect(next_url)
+
+ form = SendEmailForm(request.form)
+ if form.validate_on_submit():
+ text = form.text.data
+ html = markdown(text)
+ task = sendEmailRaw.delay([user.email], form.subject.data, text, html)
+ return redirect(url_for("tasks.check", id=task.id, r=next_url))
+
+ return render_template("users/send_email.html", form=form)
+
+
+
+class SetPasswordForm(FlaskForm):
+ email = StringField("Email", [Optional(), Email()])
+ password = PasswordField("New password", [InputRequired(), Length(2, 100)])
+ password2 = PasswordField("Verify password", [InputRequired(), Length(2, 100)])
+ submit = SubmitField("Save")
+
+@bp.route("/user/set-password/", methods=["GET", "POST"])
+@login_required
+def set_password():
+ if current_user.password is not None:
+ return redirect(url_for("user.change_password"))
+
+ form = SetPasswordForm(request.form)
+ if current_user.email == None:
+ form.email.validators = [InputRequired(), Email()]
+
+ if request.method == "POST" and form.validate():
+ one = form.password.data
+ two = form.password2.data
+ if one == two:
+ # Hash password
+ hashed_password = user_manager.hash_password(form.password.data)
+
+ # Change password
+ user_manager.update_password(current_user, hashed_password)
+
+ # Send 'password_changed' email
+ if user_manager.enable_email and user_manager.send_password_changed_email and current_user.email:
+ emails.send_password_changed_email(current_user)
+
+ # Send password_changed signal
+ signals.user_changed_password.send(current_app._get_current_object(), user=current_user)
+
+ # Prepare one-time system message
+ flash('Your password has been changed successfully.', 'success')
+
+ newEmail = form["email"].data
+ if newEmail != current_user.email and newEmail.strip() != "":
+ token = randomString(32)
+
+ ver = UserEmailVerification()
+ ver.user = current_user
+ ver.token = token
+ ver.email = newEmail
+ db.session.add(ver)
+ db.session.commit()
+
+ task = sendVerifyEmail.delay(newEmail, token)
+ return redirect(url_for("tasks.check", id=task.id, r=url_for("users.profile", username=current_user.username)))
+ else:
+ return redirect(url_for("users.profile", username=current_user.username))
+ else:
+ flash("Passwords do not match", "error")
+
+ return render_template("users/set_password.html", form=form, optional=request.args.get("optional"))
+
+
+@bp.route("/user/claim/", methods=["GET", "POST"])
+def claim():
+ username = request.args.get("username")
+ if username is None:
+ username = ""
+ else:
+ method = request.args.get("method")
+ user = User.query.filter_by(forums_username=username).first()
+ if user and user.rank.atLeast(UserRank.NEW_MEMBER):
+ flash("User has already been claimed", "error")
+ return redirect(url_for("users.claim"))
+ elif user is None and method == "github":
+ flash("Unable to get Github username for user", "error")
+ return redirect(url_for("users.claim"))
+ elif user is None:
+ flash("Unable to find that user", "error")
+ return redirect(url_for("users.claim"))
+
+ if user is not None and method == "github":
+ return redirect(url_for("users.github_signin"))
+
+ token = None
+ if "forum_token" in session:
+ token = session["forum_token"]
+ else:
+ token = randomString(32)
+ session["forum_token"] = token
+
+ if request.method == "POST":
+ ctype = request.form.get("claim_type")
+ username = request.form.get("username")
+
+ if username is None or len(username.strip()) < 2:
+ flash("Invalid username", "error")
+ elif ctype == "github":
+ task = checkForumAccount.delay(username)
+ return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim", username=username, method="github")))
+ elif ctype == "forum":
+ user = User.query.filter_by(forums_username=username).first()
+ if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
+ flash("That user has already been claimed!", "error")
+ return redirect(url_for("users.claim"))
+
+ # Get signature
+ sig = None
+ try:
+ profile = getProfile("https://forum.minetest.net", username)
+ sig = profile.signature
+ except IOError:
+ flash("Unable to get forum signature - does the user exist?", "error")
+ return redirect(url_for("users.claim", username=username))
+
+ # Look for key
+ if token in sig:
+ if user is None:
+ user = User(username)
+ user.forums_username = username
+ db.session.add(user)
+ db.session.commit()
+
+ if loginUser(user):
+ return redirect(url_for("users.set_password"))
+ else:
+ flash("Unable to login as user", "error")
+ return redirect(url_for("users.claim", username=username))
+
+ else:
+ flash("Could not find the key in your signature!", "error")
+ return redirect(url_for("users.claim", username=username))
+ else:
+ flash("Unknown claim type", "error")
+
+ return render_template("users/claim.html", username=username, key=token)
+
+@bp.route("/users/verify/")
+def verify_email():
+ token = request.args.get("token")
+ ver = UserEmailVerification.query.filter_by(token=token).first()
+ if ver is None:
+ flash("Unknown verification token!", "error")
+ else:
+ ver.user.email = ver.email
+ db.session.delete(ver)
+ db.session.commit()
+
+ if current_user.is_authenticated:
+ return redirect(url_for("users.profile", username=current_user.username))
+ else:
+ return redirect(url_for("home_page"))