mirror of
https://github.com/minetest/contentdb.git
synced 2024-12-22 22:12:24 +01:00
parent
bf20177756
commit
f7a5a1218f
293
app/blueprints/collections/__init__.py
Normal file
293
app/blueprints/collections/__init__.py
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
# ContentDB
|
||||||
|
# Copyright (C) 2023 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 re
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from flask import Blueprint, request, redirect, render_template, flash, abort
|
||||||
|
from flask_babel import lazy_gettext, gettext
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, BooleanField, SubmitField, FieldList, HiddenField
|
||||||
|
from wtforms.validators import InputRequired, Length, Optional
|
||||||
|
|
||||||
|
from app.models import Collection, db, Package, Permission, CollectionPackage, User, UserRank, AuditSeverity
|
||||||
|
from app.utils import is_package_page, nonempty_or_none, add_audit_log
|
||||||
|
|
||||||
|
bp = Blueprint("collections", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/collections/")
|
||||||
|
@bp.route("/collections/<author>/")
|
||||||
|
def list_all(author=None):
|
||||||
|
if author:
|
||||||
|
user = User.query.filter_by(username=author).one_or_404()
|
||||||
|
query = user.collections
|
||||||
|
else:
|
||||||
|
user = None
|
||||||
|
query = Collection.query.order_by(db.asc(Collection.title))
|
||||||
|
|
||||||
|
if "package" in request.args:
|
||||||
|
package = Package.get_by_key(request.args["package"])
|
||||||
|
if package is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
query = query.filter(Collection.packages.contains(package))
|
||||||
|
|
||||||
|
collections = [x for x in query.all() if x.check_perm(current_user, Permission.VIEW_COLLECTION)]
|
||||||
|
return render_template("collections/list.html",
|
||||||
|
user=user, collections=collections,
|
||||||
|
noindex=user is None or len(collections) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/collections/<author>/<name>/")
|
||||||
|
def view(author, name):
|
||||||
|
collection = Collection.query \
|
||||||
|
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||||
|
.one_or_404()
|
||||||
|
|
||||||
|
if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return render_template("collections/view.html", collection=collection)
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionForm(FlaskForm):
|
||||||
|
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
|
||||||
|
short_description = StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 200)])
|
||||||
|
private = BooleanField(lazy_gettext("Private"))
|
||||||
|
descriptions = FieldList(
|
||||||
|
StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 500)], filters=[nonempty_or_none]),
|
||||||
|
min_entries=0)
|
||||||
|
package_ids = FieldList(HiddenField(), min_entries=0)
|
||||||
|
submit = SubmitField(lazy_gettext("Save"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/collections/new/", methods=["GET", "POST"])
|
||||||
|
@bp.route("/collections/<author>/<name>/edit/", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def create_edit(author=None, name=None):
|
||||||
|
collection: typing.Optional[Collection] = None
|
||||||
|
if author is not None and name is not None:
|
||||||
|
collection = Collection.query \
|
||||||
|
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||||
|
.one_or_404()
|
||||||
|
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||||
|
abort(403)
|
||||||
|
elif "author" in request.args:
|
||||||
|
author = request.args["author"]
|
||||||
|
if author != current_user.username and not current_user.rank.at_least(UserRank.EDITOR):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if author is None:
|
||||||
|
author = current_user
|
||||||
|
else:
|
||||||
|
author = User.query.filter_by(username=author).one()
|
||||||
|
|
||||||
|
form = CollectionForm(formdata=request.form, obj=collection)
|
||||||
|
|
||||||
|
initial_package = None
|
||||||
|
if "package" in request.args:
|
||||||
|
initial_package = Package.get_by_key(request.args["package"])
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
# HACK: fix bug in wtforms
|
||||||
|
form.private.data = collection.private if collection else False
|
||||||
|
if collection:
|
||||||
|
for item in collection.items:
|
||||||
|
form.descriptions.append_entry(item.description)
|
||||||
|
form.package_ids.append_entry(item.package.id)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
ret = handle_create_edit(collection, form, initial_package, author)
|
||||||
|
if ret:
|
||||||
|
return ret
|
||||||
|
|
||||||
|
return render_template("collections/create_edit.html",
|
||||||
|
collection=collection, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
regex_invalid_chars = re.compile("[^a-z_]")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_create_edit(collection: Collection, form: CollectionForm,
|
||||||
|
initial_package: typing.Optional[Package], author: User):
|
||||||
|
|
||||||
|
severity = AuditSeverity.NORMAL if author == current_user else AuditSeverity.EDITOR
|
||||||
|
name = collection.name if collection else regex_invalid_chars.sub("", form.title.data.lower().replace(" ", "_"))
|
||||||
|
|
||||||
|
if collection is None:
|
||||||
|
if Collection.query \
|
||||||
|
.filter(Collection.name == name, Collection.author == author) \
|
||||||
|
.count() > 0:
|
||||||
|
flash(gettext("A collection with a similar title already exists"), "danger")
|
||||||
|
return
|
||||||
|
|
||||||
|
if Package.query \
|
||||||
|
.filter(Package.name == name, Package.author == author) \
|
||||||
|
.count() > 0:
|
||||||
|
flash(gettext("Unable to create collection as a package with that name already exists"), "danger")
|
||||||
|
return
|
||||||
|
|
||||||
|
collection = Collection()
|
||||||
|
collection.name = name
|
||||||
|
collection.author = author
|
||||||
|
db.session.add(collection)
|
||||||
|
|
||||||
|
if initial_package:
|
||||||
|
link = CollectionPackage()
|
||||||
|
link.package = initial_package
|
||||||
|
link.collection = collection
|
||||||
|
link.order = len(collection.items)
|
||||||
|
db.session.add(link)
|
||||||
|
|
||||||
|
add_audit_log(severity, current_user,
|
||||||
|
f"Created collection {collection.author.username}/{collection.name}",
|
||||||
|
collection.get_url("collections.view"), None)
|
||||||
|
|
||||||
|
else:
|
||||||
|
for i, package_id in enumerate(form.package_ids):
|
||||||
|
item = next((x for x in collection.items if str(x.package.id) == package_id.data), None)
|
||||||
|
if item is None:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
item.description = form.descriptions[i].data
|
||||||
|
|
||||||
|
add_audit_log(severity, current_user,
|
||||||
|
f"Edited collection {collection.author.username}/{collection.name}",
|
||||||
|
collection.get_url("collections.view"), None)
|
||||||
|
|
||||||
|
form.populate_obj(collection)
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(collection.get_url("collections.view"))
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_package(collection: Collection, package: Package):
|
||||||
|
severity = AuditSeverity.NORMAL if collection.author == current_user else AuditSeverity.EDITOR
|
||||||
|
|
||||||
|
if package in collection.packages:
|
||||||
|
CollectionPackage.query \
|
||||||
|
.filter(CollectionPackage.collection == collection, CollectionPackage.package == package) \
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
add_audit_log(severity, current_user,
|
||||||
|
f"Removed {package.get_id()} from collection {collection.author.username}/{collection.name}",
|
||||||
|
collection.get_url("collections.view"), None)
|
||||||
|
db.session.commit()
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
link = CollectionPackage()
|
||||||
|
link.package = package
|
||||||
|
link.collection = collection
|
||||||
|
link.order = len(collection.items)
|
||||||
|
db.session.add(link)
|
||||||
|
add_audit_log(severity, current_user,
|
||||||
|
f"Added {package.get_id()} to collection {collection.author.username}/{collection.name}",
|
||||||
|
collection.get_url("collections.view"), None)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/packages/<author>/<name>/add-to/", methods=["GET", "POST"])
|
||||||
|
@is_package_page
|
||||||
|
@login_required
|
||||||
|
def package_add(package):
|
||||||
|
if request.method == "POST":
|
||||||
|
collection_id = request.form["collection"]
|
||||||
|
collection = Collection.query.get(collection_id)
|
||||||
|
if collection is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if toggle_package(collection, package):
|
||||||
|
flash(gettext("Added package to collection"), "success")
|
||||||
|
else:
|
||||||
|
flash(gettext("Removed package from collection"), "success")
|
||||||
|
|
||||||
|
return redirect(package.get_url("collections.package_add"))
|
||||||
|
|
||||||
|
collections = current_user.collections.all()
|
||||||
|
if current_user.rank.at_least(UserRank.EDITOR) and current_user.username != "ContentDB":
|
||||||
|
collections.extend(Collection.query.filter(Collection.author.has(username="ContentDB")).all())
|
||||||
|
|
||||||
|
return render_template("collections/package_add_to.html", package=package, collections=collections)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/packages/<author>/<name>/favorite/", methods=["POST"])
|
||||||
|
@is_package_page
|
||||||
|
@login_required
|
||||||
|
def package_toggle_favorite(package):
|
||||||
|
collection = Collection.query.filter(Collection.name == "favorites", Collection.author == current_user).first()
|
||||||
|
if collection is None:
|
||||||
|
collection = Collection()
|
||||||
|
collection.title = "Favorites"
|
||||||
|
collection.name = "favorites"
|
||||||
|
collection.short_description = "My favorites"
|
||||||
|
collection.author = current_user
|
||||||
|
db.session.add(collection)
|
||||||
|
|
||||||
|
if toggle_package(collection, package):
|
||||||
|
flash(gettext("Added package to favorites collection"), "success")
|
||||||
|
else:
|
||||||
|
flash(gettext("Removed package from favorites collection"), "success")
|
||||||
|
|
||||||
|
return redirect(package.get_url("packages.view"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/collections/<author>/<name>/clone/", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def clone(author, name):
|
||||||
|
old_collection: typing.Optional[Collection] = Collection.query \
|
||||||
|
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||||
|
.one_or_404()
|
||||||
|
|
||||||
|
index = 0
|
||||||
|
new_name = name
|
||||||
|
new_title = old_collection.title
|
||||||
|
while True:
|
||||||
|
if Collection.query \
|
||||||
|
.filter(Collection.name == new_name, Collection.author == current_user) \
|
||||||
|
.count() == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
new_name = f"{name}_{index}"
|
||||||
|
new_title = f"{old_collection.title} ({index})"
|
||||||
|
|
||||||
|
collection = Collection()
|
||||||
|
collection.title = new_title
|
||||||
|
collection.author = current_user
|
||||||
|
collection.short_description = old_collection.short_description
|
||||||
|
collection.name = new_name
|
||||||
|
collection.private = True
|
||||||
|
db.session.add(collection)
|
||||||
|
|
||||||
|
for item in old_collection.items:
|
||||||
|
new_item = CollectionPackage()
|
||||||
|
new_item.package = item.package
|
||||||
|
new_item.collection = collection
|
||||||
|
new_item.description = item.description
|
||||||
|
new_item.order = item.order
|
||||||
|
db.session.add(new_item)
|
||||||
|
|
||||||
|
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||||
|
f"Created collection {collection.name} from {old_collection.author.username}/{old_collection.name} ",
|
||||||
|
collection.get_url("collections.view"), None)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(collection.get_url("collections.view"))
|
@ -19,7 +19,7 @@ from flask import Blueprint, render_template
|
|||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from app.models import User, Package, PackageState, db, License, PackageReview
|
from app.models import User, Package, PackageState, db, License, PackageReview, Collection
|
||||||
|
|
||||||
bp = Blueprint("donate", __name__)
|
bp = Blueprint("donate", __name__)
|
||||||
|
|
||||||
@ -30,7 +30,8 @@ def donate():
|
|||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
reviewed_packages = Package.query.filter(
|
reviewed_packages = Package.query.filter(
|
||||||
Package.state == PackageState.APPROVED,
|
Package.state == PackageState.APPROVED,
|
||||||
Package.reviews.any(and_(PackageReview.author_id == current_user.id, PackageReview.rating >= 3)),
|
or_(Package.reviews.any(and_(PackageReview.author_id == current_user.id, PackageReview.rating >= 3)),
|
||||||
|
Package.collections.any(and_(Collection.author_id == current_user.id, Collection.name == "favorites"))),
|
||||||
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
|
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
|
||||||
).order_by(db.asc(Package.title)).all()
|
).order_by(db.asc(Package.title)).all()
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ from . import bp, get_package_tabs
|
|||||||
from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \
|
from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \
|
||||||
Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \
|
Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \
|
||||||
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \
|
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \
|
||||||
PackageDailyStats
|
PackageDailyStats, Collection
|
||||||
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \
|
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \
|
||||||
add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options
|
add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options
|
||||||
|
|
||||||
@ -196,11 +196,17 @@ def view(package):
|
|||||||
has_review = current_user.is_authenticated and \
|
has_review = current_user.is_authenticated and \
|
||||||
PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
||||||
|
|
||||||
|
is_favorited = current_user.is_authenticated and \
|
||||||
|
Collection.query.filter(
|
||||||
|
Collection.author == current_user,
|
||||||
|
Collection.packages.contains(package),
|
||||||
|
Collection.name == "favorites").count() > 0
|
||||||
|
|
||||||
return render_template("packages/view.html",
|
return render_template("packages/view.html",
|
||||||
package=package, releases=releases, packages_uses=packages_uses,
|
package=package, releases=releases, packages_uses=packages_uses,
|
||||||
conflicting_modnames=conflicting_modnames,
|
conflicting_modnames=conflicting_modnames,
|
||||||
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
|
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
|
||||||
threads=threads.all(), has_review=has_review)
|
threads=threads.all(), has_review=has_review, is_favorited=is_favorited)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/shields/<type>/")
|
@bp.route("/packages/<author>/<name>/shields/<type>/")
|
||||||
|
@ -31,6 +31,7 @@ make_searchable(db.metadata)
|
|||||||
from .packages import *
|
from .packages import *
|
||||||
from .users import *
|
from .users import *
|
||||||
from .threads import *
|
from .threads import *
|
||||||
|
from .collections import *
|
||||||
|
|
||||||
|
|
||||||
class APIToken(db.Model):
|
class APIToken(db.Model):
|
||||||
|
74
app/models/collections.py
Normal file
74
app/models/collections.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# ContentDB
|
||||||
|
# Copyright (C) 2023 rubenwardy
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
from . import db, Permission, User, UserRank
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionPackage(db.Model):
|
||||||
|
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), primary_key=True)
|
||||||
|
package = db.relationship("Package", foreign_keys=[package_id])
|
||||||
|
|
||||||
|
collection_id = db.Column(db.Integer, db.ForeignKey("collection.id"), primary_key=True)
|
||||||
|
collection = db.relationship("Collection", back_populates="items", foreign_keys=[collection_id])
|
||||||
|
|
||||||
|
order = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
description = db.Column(db.String, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
collection_description_nonempty = db.CheckConstraint("description = NULL OR description != ''")
|
||||||
|
|
||||||
|
|
||||||
|
class Collection(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||||
|
author = db.relationship("User", back_populates="collections", foreign_keys=[author_id])
|
||||||
|
|
||||||
|
name = db.Column(db.Unicode(100), nullable=False)
|
||||||
|
title = db.Column(db.Unicode(100), nullable=False)
|
||||||
|
short_description = db.Column(db.Unicode(200), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||||
|
private = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
packages = db.relationship("Package", secondary=CollectionPackage.__table__, backref="collections")
|
||||||
|
items = db.relationship("CollectionPackage", back_populates="collection", order_by=db.asc("created_at"))
|
||||||
|
|
||||||
|
collection_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$' AND name != '_game'")
|
||||||
|
__table_args__ = (db.UniqueConstraint("author_id", "name", name="_collection_uc"),)
|
||||||
|
|
||||||
|
def get_url(self, endpoint, **kwargs):
|
||||||
|
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
|
||||||
|
|
||||||
|
def check_perm(self, user: User, perm):
|
||||||
|
if type(perm) == str:
|
||||||
|
perm = Permission[perm]
|
||||||
|
elif type(perm) != Permission:
|
||||||
|
raise Exception("Unknown permission given to Collection.check_perm()")
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return perm == Permission.VIEW_COLLECTION and not self.private
|
||||||
|
|
||||||
|
can_view = not self.private or self.author == user or user.rank.at_least(UserRank.MODERATOR)
|
||||||
|
if perm == Permission.VIEW_COLLECTION:
|
||||||
|
return can_view
|
||||||
|
elif perm == Permission.EDIT_COLLECTION:
|
||||||
|
return can_view and (self.author == user or user.rank.at_least(UserRank.EDITOR))
|
||||||
|
else:
|
||||||
|
raise Exception("Permission {} is not related to collections".format(perm.name))
|
@ -80,14 +80,14 @@ class Thread(db.Model):
|
|||||||
return url_for("threads.unsubscribe", id=self.id)
|
return url_for("threads.unsubscribe", id=self.id)
|
||||||
|
|
||||||
def check_perm(self, user, perm):
|
def check_perm(self, user, perm):
|
||||||
if not user.is_authenticated:
|
|
||||||
return perm == Permission.SEE_THREAD and not self.private
|
|
||||||
|
|
||||||
if type(perm) == str:
|
if type(perm) == str:
|
||||||
perm = Permission[perm]
|
perm = Permission[perm]
|
||||||
elif type(perm) != Permission:
|
elif type(perm) != Permission:
|
||||||
raise Exception("Unknown permission given to Thread.check_perm()")
|
raise Exception("Unknown permission given to Thread.check_perm()")
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return perm == Permission.SEE_THREAD and not self.private
|
||||||
|
|
||||||
isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
|
isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
|
||||||
if self.package:
|
if self.package:
|
||||||
isMaintainer = isMaintainer or user in self.package.maintainers
|
isMaintainer = isMaintainer or user in self.package.maintainers
|
||||||
|
@ -90,6 +90,8 @@ class Permission(enum.Enum):
|
|||||||
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
|
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
|
||||||
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
|
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
|
||||||
VIEW_AUDIT_DESCRIPTION = "VIEW_AUDIT_DESCRIPTION"
|
VIEW_AUDIT_DESCRIPTION = "VIEW_AUDIT_DESCRIPTION"
|
||||||
|
EDIT_COLLECTION = "EDIT_COLLECTION"
|
||||||
|
VIEW_COLLECTION = "VIEW_COLLECTION"
|
||||||
|
|
||||||
# Only return true if the permission is valid for *all* contexts
|
# Only return true if the permission is valid for *all* contexts
|
||||||
# See Package.check_perm for package-specific contexts
|
# See Package.check_perm for package-specific contexts
|
||||||
@ -183,6 +185,7 @@ class User(db.Model, UserMixin):
|
|||||||
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||||
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
|
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
|
||||||
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||||
|
collections = db.relationship("Collection", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.asc("title"))
|
||||||
|
|
||||||
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
|
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
|
||||||
|
|
||||||
|
@ -142,6 +142,11 @@
|
|||||||
{{ _("To do list") }}
|
{{ _("To do list") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('collections.list_all', author=current_user.username) }}">
|
||||||
|
{{ _("My Collections") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('users.statistics', username=current_user.username) }}">
|
<a class="nav-link" href="{{ url_for('users.statistics', username=current_user.username) }}">
|
||||||
{{ _("Statistics") }}
|
{{ _("Statistics") }}
|
||||||
|
47
app/templates/collections/create_edit.html
Normal file
47
app/templates/collections/create_edit.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% if collection %}
|
||||||
|
{{ _("Edit") }} - {{ collection.title }}
|
||||||
|
{% else %}
|
||||||
|
{{ _("New Collection") }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
|
||||||
|
<form method="POST" action="" enctype="multipart/form-data">
|
||||||
|
{{ render_submit_field(form.submit, class_="btn btn-primary float-right") }}
|
||||||
|
<h1>{{ self.title() }}</h1>
|
||||||
|
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
{{ render_field(form.title) }}
|
||||||
|
{{ render_field(form.short_description) }}
|
||||||
|
{{ render_checkbox_field(form.private, class_="my-3") }}
|
||||||
|
|
||||||
|
{% if collection and collection.items %}
|
||||||
|
<h2>{{ _("Packages") }}</h2>
|
||||||
|
<p class="text-muted">
|
||||||
|
{{ _("To add or remove a package, go to the package's page and click 'Add to collection'") }}
|
||||||
|
</p>
|
||||||
|
{% for item in collection.items %}
|
||||||
|
{% set package = item.package %}
|
||||||
|
<article class="card my-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>
|
||||||
|
<a href="{{ package.get_url('packages.view') }}" target="_blank">
|
||||||
|
{{ _("%(title)s by %(author)s", title=package.title, author=package.author.display_name) }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
{{ render_field(form.descriptions[loop.index - 1]) }}
|
||||||
|
{{ form.package_ids[loop.index - 1]() }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
{{ render_submit_field(form.submit) }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
53
app/templates/collections/list.html
Normal file
53
app/templates/collections/list.html
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% if user %}
|
||||||
|
{{ _("%(author)s's collections", author=user.display_name) }}
|
||||||
|
{% else %}
|
||||||
|
{{ _("Collections") }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block author_link -%}
|
||||||
|
<a href="{{ url_for('users.profile', username=user.username) }}">
|
||||||
|
{{- user.display_name -}}
|
||||||
|
</a>
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if user %}
|
||||||
|
{% if current_user == user or (current_user.is_authenticated and current_user.rank.at_least(current_user.rank.EDITOR)) %}
|
||||||
|
<a class="btn btn-primary float-right" href="{{ url_for('collections.create_edit', author=user.username) }}">
|
||||||
|
{{ _("Create") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<h1>{{ _("%(author)s's collections", author=self.author_link()) }}</h1>
|
||||||
|
{% else %}
|
||||||
|
<h1>{{ _("Collections") }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="list-group">
|
||||||
|
{% for collection in collections -%}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ collection.get_url('collections.view') }}">
|
||||||
|
{% if collection.private %}
|
||||||
|
<i class="fas fa-lock mr-1" style="color:#ffac33;"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% if collection.name == 'favorites' %}
|
||||||
|
<i class="fas fa-heart mr-1 text-danger"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% if user != collection.author %}
|
||||||
|
{{ _("%(title)s by %(author)s", title=collection.title, author=collection.author.display_name) }}
|
||||||
|
{% else %}
|
||||||
|
{{ collection.title }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-muted ml-4">
|
||||||
|
{{ collection.short_description }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item text-muted">
|
||||||
|
<i>{{ _("No collections") }}</i>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
49
app/templates/collections/package_add_to.html
Normal file
49
app/templates/collections/package_add_to.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{{ _("Add %(package_title)s to a collection", package_title=package.title)}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a href="{{ url_for('collections.create_edit', package=package.get_id()) }}" class="btn btn-primary float-right">
|
||||||
|
{{ _("Create Collection") }}
|
||||||
|
</a>
|
||||||
|
<h1>{{ self.title() }}</h1>
|
||||||
|
|
||||||
|
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
|
||||||
|
<div class="list-group my-4">
|
||||||
|
{% for collection in collections %}
|
||||||
|
{% set active = package in collection.packages %}
|
||||||
|
<form method="POST" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="collection" value="{{ collection.id }}" />
|
||||||
|
<button type="submit"
|
||||||
|
class="list-group-item list-group-item-action {% if active %}active{% endif %}">
|
||||||
|
{% if active %}
|
||||||
|
<i class="fas fa-check mr-3 text-success"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-square mr-3 text-muted"></i>
|
||||||
|
{% endif -%}
|
||||||
|
{% if collection.author != current_user %}{{ collection.author.display_name }}: {% endif -%}
|
||||||
|
{% if collection.name == 'favorites' %}
|
||||||
|
<i class="fas fa-heart mr-1 text-danger"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% if collection.private %}
|
||||||
|
<i class="fas fa-lock mr-1" style="color:#ffac33;"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ collection.title }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item text-muted">
|
||||||
|
<i>{{ _("You don't have any collections") }}</i>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{{ package.get_url('packages.view') }}" class="btn btn-secondary">
|
||||||
|
{{ _("Done") }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
83
app/templates/collections/view.html
Normal file
83
app/templates/collections/view.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{{ collection.title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block description -%}
|
||||||
|
{{ collection.short_description }}
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block author_link -%}
|
||||||
|
<a href="{{ url_for('users.profile', username=collection.author.username) }}">
|
||||||
|
{{ collection.author.display_name }}
|
||||||
|
</a>
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="float-right">
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('collections.list_all', author=collection.author.username) }}">
|
||||||
|
{{ _("%(author)s's collections", author=collection.author.display_name) }}
|
||||||
|
</a>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<form method="POST" action="{{ collection.get_url('collections.clone') }}" class="d-inline-block ml-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<button type="submit" class="btn btn-secondary">
|
||||||
|
{{ _("Make a copy") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if collection.check_perm(current_user, "EDIT_COLLECTION") %}
|
||||||
|
<a class="btn btn-primary ml-2" href="{{ collection.get_url('collections.create_edit') }}">
|
||||||
|
{{ _("Edit") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h1>{{ self.title() }}</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% if collection.private %}
|
||||||
|
<span class="badge badge-secondary mr-1">
|
||||||
|
<i class="fas fa-lock mr-1" style="color:#ffac33;"></i>
|
||||||
|
{{ _("Private") }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{{ _("A collection by %(author)s", author=self.author_link()) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ collection.short_description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="mt-5">
|
||||||
|
<h2 class="sr-only">{{ _("Packages") }}</h2>
|
||||||
|
{% if not collection.items %}
|
||||||
|
<p class="text-muted">
|
||||||
|
{{ _("To add a package, go to the package's page and click 'Add to collection'") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="grid-2 gap-3">
|
||||||
|
{% for item in collection.items %}
|
||||||
|
<div class="">
|
||||||
|
<article class="card">
|
||||||
|
<div class="embed-responsive embed-responsive-16by9">
|
||||||
|
<img class="card-img-top embed-responsive-item" src="{{ item.package.get_thumb_url(4) }}" alt="{{ item.package.title }} screenshot">
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mt-0">
|
||||||
|
<a href="{{ item.package.get_url('packages.view') }}">
|
||||||
|
{{ item.package.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<p class="card-text">
|
||||||
|
{% if item.description %}
|
||||||
|
{{ item.description }}
|
||||||
|
{% else %}
|
||||||
|
{{ item.description or item.package.short_desc }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
@ -59,7 +59,7 @@
|
|||||||
{{ self.description() }}
|
{{ self.description() }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>{{ _("Based on your reviews") }}</h2>
|
<h2>{{ _("Based on your reviews / favorites") }}</h2>
|
||||||
{% if reviewed_packages %}
|
{% if reviewed_packages %}
|
||||||
{{ render_packages(reviewed_packages) }}
|
{{ render_packages(reviewed_packages) }}
|
||||||
{% elif current_user.is_authenticated %}
|
{% elif current_user.is_authenticated %}
|
||||||
|
@ -348,6 +348,22 @@
|
|||||||
{{ self.download_btn() }}
|
{{ self.download_btn() }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<form method="POST" action="{{ package.get_url('collections.package_toggle_favorite') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<button type="submit" class="btn {% if is_favorited %}text-danger{% endif %} btn-secondary mr-2" aria-label="{{ _('Favorite') }}">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<a href="{{ package.get_url('collections.package_add') }}" class="btn btn-block btn-secondary">
|
||||||
|
{{ _("Add to collection...") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if package.check_perm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %}
|
{% if package.check_perm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %}
|
||||||
{% set config = package.update_config %}
|
{% set config = package.update_config %}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
|
@ -9,7 +9,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<article class="row mb-5">
|
<article class="row mb-5">
|
||||||
<div class="col-auto image mx-0">
|
<div class="col-auto image mx-0">
|
||||||
<img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ user.get_profile_pic_url() }}" alt="{{ _('Profile picture') }}">
|
<img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ user.get_profile_pic_url() }}" alt="{{ _('Profile picture') }}">
|
||||||
@ -110,6 +109,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{% set collection_count = user.collections.filter_by(private=False).count() %}
|
||||||
|
<a class="btn" href="{{ url_for('collections.list_all', author=user.username) }}">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
<span class="count">
|
||||||
|
<strong>{{ collection_count }}</strong>
|
||||||
|
{{ _("collections") }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a class="btn" href="#reviews">
|
<a class="btn" href="#reviews">
|
||||||
<i class="fas fa-star-half-alt"></i>
|
<i class="fas fa-star-half-alt"></i>
|
||||||
<span class="count">
|
<span class="count">
|
||||||
|
53
migrations/versions/89dfa0043f9c_.py
Normal file
53
migrations/versions/89dfa0043f9c_.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 89dfa0043f9c
|
||||||
|
Revises: 2ecff2f9972d
|
||||||
|
Create Date: 2023-08-14 15:26:43.670064
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '89dfa0043f9c'
|
||||||
|
down_revision = '2ecff2f9972d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table('collection',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.Unicode(length=100), nullable=False),
|
||||||
|
sa.Column('title', sa.Unicode(length=100), nullable=False),
|
||||||
|
sa.Column('short_description', sa.Unicode(length=200), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('private', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['author_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('author_id', 'name', name='_collection_uc')
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table('collection_package',
|
||||||
|
sa.Column('package_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('collection_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['package_id'], ['package.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('package_id', 'collection_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint("collection_name_valid", "collection",
|
||||||
|
"name ~* '^[a-z0-9_]+$' AND name != '_game'")
|
||||||
|
op.create_check_constraint("collection_description_nonempty", "collection_package",
|
||||||
|
"description = NULL OR description != ''")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_constraint("collection_name_valid", "collection", type_="check")
|
||||||
|
op.drop_constraint("collection_description_nonempty", "collection_package", type_="check")
|
||||||
|
op.drop_table('collection_package')
|
||||||
|
op.drop_table('collection')
|
||||||
|
# ### end Alembic commands ###
|
Loading…
Reference in New Issue
Block a user