Add package collections

Fixes #378
This commit is contained in:
rubenwardy 2023-08-14 21:48:50 +01:00
parent bf20177756
commit f7a5a1218f
17 changed files with 702 additions and 10 deletions

@ -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>/")

@ -484,4 +484,4 @@ Supported query parameters:
* `body`: markup for long description. * `body`: markup for long description.
* `links`: dictionary of anchor name to link URL. * `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL * `images`: dictionary of img name to image URL
* `image_tooltips`: dictionary of img name to tooltip text. * `image_tooltips`: dictionary of img name to tooltip text.

@ -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

@ -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") }}

@ -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 %}

@ -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 %}

@ -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 %}

@ -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">

@ -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 ###