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") }}
+
+
+ {{ _("My Collections") }}
+
+
{{ _("Statistics") }}
diff --git a/app/templates/collections/create_edit.html b/app/templates/collections/create_edit.html
new file mode 100644
index 00000000..e003097d
--- /dev/null
+++ b/app/templates/collections/create_edit.html
@@ -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 %}
+
+{% endblock %}
diff --git a/app/templates/collections/list.html b/app/templates/collections/list.html
new file mode 100644
index 00000000..bf9df696
--- /dev/null
+++ b/app/templates/collections/list.html
@@ -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 -%}
+
+ {{- user.display_name -}}
+
+{%- endblock %}
+
+{% block content %}
+ {% if user %}
+ {% if current_user == user or (current_user.is_authenticated and current_user.rank.at_least(current_user.rank.EDITOR)) %}
+
+ {{ _("Create") }}
+
+ {% endif %}
+ {{ _("%(author)s's collections", author=self.author_link()) }}
+ {% else %}
+ {{ _("Collections") }}
+ {% endif %}
+
+
+{% endblock %}
diff --git a/app/templates/collections/package_add_to.html b/app/templates/collections/package_add_to.html
new file mode 100644
index 00000000..d090c2fb
--- /dev/null
+++ b/app/templates/collections/package_add_to.html
@@ -0,0 +1,49 @@
+{% extends "base.html" %}
+
+{% block title %}
+ {{ _("Add %(package_title)s to a collection", package_title=package.title)}}
+{% endblock %}
+
+{% block content %}
+
+ {{ _("Create Collection") }}
+
+ {{ self.title() }}
+
+ {% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
+
+ {% for collection in collections %}
+ {% set active = package in collection.packages %}
+
+ {% else %}
+
+ {{ _("You don't have any collections") }}
+
+ {% endfor %}
+
+
+
+
+ {{ _("Done") }}
+
+
+{% endblock %}
diff --git a/app/templates/collections/view.html b/app/templates/collections/view.html
new file mode 100644
index 00000000..523baa94
--- /dev/null
+++ b/app/templates/collections/view.html
@@ -0,0 +1,83 @@
+{% extends "base.html" %}
+
+{% block title %}
+ {{ collection.title }}
+{% endblock %}
+
+{% block description -%}
+ {{ collection.short_description }}
+{%- endblock %}
+
+{% block author_link -%}
+
+ {{ collection.author.display_name }}
+
+{%- endblock %}
+
+{% block content %}
+
+ {{ self.title() }}
+
+ {% if collection.private %}
+
+
+ {{ _("Private") }}
+
+ {% endif %}
+ {{ _("A collection by %(author)s", author=self.author_link()) }}
+
+
+ {{ collection.short_description }}
+
+
+
+ {{ _("Packages") }}
+ {% if not collection.items %}
+
+ {{ _("To add a package, go to the package's page and click 'Add to collection'") }}
+
+ {% endif %}
+
+ {% for item in collection.items %}
+
+
+
+
+
+
+
+
+ {% if item.description %}
+ {{ item.description }}
+ {% else %}
+ {{ item.description or item.package.short_desc }}
+ {% endif %}
+
+
+
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/app/templates/donate/index.html b/app/templates/donate/index.html
index a5fbf99c..43c4ee38 100644
--- a/app/templates/donate/index.html
+++ b/app/templates/donate/index.html
@@ -59,7 +59,7 @@
{{ self.description() }}
- {{ _("Based on your reviews") }}
+ {{ _("Based on your reviews / favorites") }}
{% if reviewed_packages %}
{{ render_packages(reviewed_packages) }}
{% elif current_user.is_authenticated %}
diff --git a/app/templates/packages/view.html b/app/templates/packages/view.html
index b159373c..f6112db4 100644
--- a/app/templates/packages/view.html
+++ b/app/templates/packages/view.html
@@ -348,6 +348,22 @@
{{ self.download_btn() }}
+ {% if current_user.is_authenticated %}
+
+ {% endif %}
+
{% if package.check_perm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %}
{% set config = package.update_config %}
diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html
index 34024e62..6b199f1b 100644
--- a/app/templates/users/profile.html
+++ b/app/templates/users/profile.html
@@ -9,7 +9,6 @@
{% endblock %}
{% block content %}
-
@@ -110,6 +109,15 @@
+ {% set collection_count = user.collections.filter_by(private=False).count() %}
+
+
+
+ {{ collection_count }}
+ {{ _("collections") }}
+
+
+
diff --git a/migrations/versions/89dfa0043f9c_.py b/migrations/versions/89dfa0043f9c_.py
new file mode 100644
index 00000000..e9ba3d0c
--- /dev/null
+++ b/migrations/versions/89dfa0043f9c_.py
@@ -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 ###