diff --git a/app/blueprints/collections/__init__.py b/app/blueprints/collections/__init__.py new file mode 100644 index 00000000..c723cf87 --- /dev/null +++ b/app/blueprints/collections/__init__.py @@ -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 . + +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//") +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///") +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///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///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///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///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")) diff --git a/app/blueprints/donate/__init__.py b/app/blueprints/donate/__init__.py index 64355d01..25b4b51e 100644 --- a/app/blueprints/donate/__init__.py +++ b/app/blueprints/donate/__init__.py @@ -19,7 +19,7 @@ from flask import Blueprint, render_template from flask_login import current_user 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__) @@ -30,7 +30,8 @@ def donate(): if current_user.is_authenticated: reviewed_packages = Package.query.filter( 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))) ).order_by(db.asc(Package.title)).all() diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py index abf5ed4b..6139e0fd 100644 --- a/app/blueprints/packages/packages.py +++ b/app/blueprints/packages/packages.py @@ -42,7 +42,7 @@ from . import bp, get_package_tabs from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \ Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \ 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, \ 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 \ 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", package=package, releases=releases, packages_uses=packages_uses, conflicting_modnames=conflicting_modnames, 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///shields//") diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index 1a39ad21..cde757ec 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -484,4 +484,4 @@ Supported query parameters: * `body`: markup for long description. * `links`: dictionary of anchor name to link URL. * `images`: dictionary of img name to image URL - * `image_tooltips`: dictionary of img name to tooltip text. \ No newline at end of file + * `image_tooltips`: dictionary of img name to tooltip text. diff --git a/app/models/__init__.py b/app/models/__init__.py index b350ac05..73e1f44f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -31,6 +31,7 @@ make_searchable(db.metadata) from .packages import * from .users import * from .threads import * +from .collections import * class APIToken(db.Model): diff --git a/app/models/collections.py b/app/models/collections.py new file mode 100644 index 00000000..2f52b7e2 --- /dev/null +++ b/app/models/collections.py @@ -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 . + +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)) diff --git a/app/models/threads.py b/app/models/threads.py index 21ea34af..5aa26f7a 100644 --- a/app/models/threads.py +++ b/app/models/threads.py @@ -80,14 +80,14 @@ class Thread(db.Model): return url_for("threads.unsubscribe", id=self.id) def check_perm(self, user, perm): - if not user.is_authenticated: - return perm == Permission.SEE_THREAD and not self.private - if type(perm) == str: perm = Permission[perm] elif type(perm) != Permission: 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) if self.package: isMaintainer = isMaintainer or user in self.package.maintainers diff --git a/app/models/users.py b/app/models/users.py index a27eca98..ef0b27e5 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -90,6 +90,8 @@ class Permission(enum.Enum): CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS" CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME" 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 # 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") 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") + 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) diff --git a/app/templates/base.html b/app/templates/base.html index 2845d38c..b21647bd 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -142,6 +142,11 @@ {{ _("To do list") }} +