contentdb/app/logic/package_approval.py

203 lines
7.8 KiB
Python

# ContentDB
# Copyright (C) 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/>.
from typing import List, Tuple, Union, Optional
from flask_babel import lazy_gettext, LazyString
from sqlalchemy import and_, or_
from app.models import Package, PackageType, PackageState, PackageRelease, db, MetaPackage, ForumTopic, User, \
Permission, UserRank
class PackageValidationNote:
# level is danger, warning, or info
level: str
message: LazyString
buttons: List[Tuple[str, LazyString]]
# False to prevent "Approve"
allow_approval: bool
# False to prevent "Submit for Approval"
allow_submit: bool
def __init__(self, level: str, message: LazyString, allow_approval: bool, allow_submit: bool):
self.level = level
self.message = message
self.buttons = []
self.allow_approval = allow_approval
self.allow_submit = allow_submit
def add_button(self, url: str, label: LazyString) -> "PackageValidationNote":
self.buttons.append((url, label))
return self
def is_package_name_taken(normalised_name: str) -> bool:
return Package.query.filter(
and_(Package.state == PackageState.APPROVED,
or_(Package.name == normalised_name,
Package.name == normalised_name + "_game"))).count() > 0
def get_conflicting_mod_names(package: Package) -> set[str]:
conflicting_modnames = (db.session.query(MetaPackage.name)
.filter(MetaPackage.id.in_([mp.id for mp in package.provides]))
.filter(MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED)))
.all())
conflicting_modnames += (db.session.query(ForumTopic.name)
.filter(ForumTopic.name.in_([mp.name for mp in package.provides]))
.filter(ForumTopic.topic_id != package.forums)
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id))
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title))
.all())
return set([x[0] for x in conflicting_modnames])
def count_packages_with_forum_topic(topic_id: int) -> int:
return Package.query.filter(Package.forums == topic_id, Package.state != PackageState.DELETED).count() > 1
def get_forum_topic(topic_id: int) -> Optional[ForumTopic]:
return ForumTopic.query.get(topic_id)
def validate_package_for_approval(package: Package) -> List[PackageValidationNote]:
retval: List[PackageValidationNote] = []
def template(level: str, allow_approval: bool, allow_submit: bool):
def inner(msg: LazyString):
note = PackageValidationNote(level, msg, allow_approval, allow_submit)
retval.append(note)
return note
return inner
danger = template("danger", allow_approval=False, allow_submit=False)
warning = template("warning", allow_approval=True, allow_submit=True)
info = template("info", allow_approval=False, allow_submit=True)
if package.type != PackageType.MOD and is_package_name_taken(package.normalised_name):
danger(lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3"))
if package.releases.filter(PackageRelease.task_id.is_(None)).count() == 0:
if package.releases.count() == 0:
message = lazy_gettext("You need to create a release before this package can be approved.")
else:
message = lazy_gettext("Release is still importing, or has an error.")
danger(message) \
.add_button(package.get_url("packages.create_release"), lazy_gettext("Create release")) \
.add_button(package.get_url("packages.setup_releases"), lazy_gettext("Set up releases"))
# Don't bother validating any more until we have a release
return retval
if (package.type == PackageType.GAME or package.type == PackageType.TXP) and \
package.screenshots.count() == 0:
danger(lazy_gettext("You need to add at least one screenshot."))
missing_deps = package.get_missing_hard_dependencies_query().all()
if len(missing_deps) > 0:
missing_deps = ", ".join([ x.name for x in missing_deps])
danger(lazy_gettext(
"The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=missing_deps))
if package.type != PackageType.GAME and not package.supports_all_games and package.supported_games.count() == 0:
danger(lazy_gettext(
"What games does your package support? Please specify on the supported games page", deps=missing_deps)) \
.add_button(package.get_url("packages.game_support"), lazy_gettext("Supported Games"))
if "Other" in package.license.name or "Other" in package.media_license.name:
info(lazy_gettext("Please wait for the license to be added to CDB."))
# Check similar mod name
conflicting_modnames = set()
if package.type != PackageType.TXP:
conflicting_modnames = get_conflicting_mod_names(package)
if len(conflicting_modnames) > 4:
warning(lazy_gettext("Please make sure that this package has the right to the names it uses."))
elif len(conflicting_modnames) > 0:
names_list = list(conflicting_modnames)
names_list.sort()
warning(lazy_gettext("Please make sure that this package has the right to the names %(names)s",
names=", ".join(names_list))) \
.add_button(package.get_url('packages.similar'), lazy_gettext("See more"))
# Check forum topic
if package.state != PackageState.APPROVED and package.forums is not None:
if count_packages_with_forum_topic(package.forums) > 1:
danger("<b>" + lazy_gettext("Error: Another package already uses this forum topic!") + "</b>")
topic = get_forum_topic(package.forums)
if topic is not None:
if topic.author != package.author:
danger("<b>" + lazy_gettext("Error: Forum topic author doesn't match package author.") + "</b>")
elif package.type != PackageType.TXP:
warning(lazy_gettext("Warning: Forum topic not found. The topic may have been created since the last forum crawl."))
return retval
PACKAGE_STATE_FLOW = {
PackageState.WIP: {PackageState.READY_FOR_REVIEW},
PackageState.CHANGES_NEEDED: {PackageState.READY_FOR_REVIEW},
PackageState.READY_FOR_REVIEW: {PackageState.WIP, PackageState.CHANGES_NEEDED, PackageState.APPROVED},
PackageState.APPROVED: {PackageState.CHANGES_NEEDED},
PackageState.DELETED: {PackageState.READY_FOR_REVIEW},
}
def can_move_to_state(package: Package, user: User, new_state: Union[str, PackageState]) -> bool:
if not user.is_authenticated:
return False
if type(new_state) == str:
new_state = PackageState[new_state]
elif type(new_state) != PackageState:
raise Exception("Unknown state given to can_move_to_state()")
if new_state not in PACKAGE_STATE_FLOW[package.state]:
return False
if new_state == PackageState.READY_FOR_REVIEW or new_state == PackageState.APPROVED:
# Can the user approve?
if new_state == PackageState.APPROVED and not package.check_perm(user, Permission.APPROVE_NEW):
return False
# Must be able to edit or approve package to change its state
if not (package.check_perm(user, Permission.APPROVE_NEW) or package.check_perm(user, Permission.EDIT_PACKAGE)):
return False
# Are there any validation warnings?
validation_notes = validate_package_for_approval(package)
for note in validation_notes:
if not note.allow_submit or (new_state == PackageState.APPROVED and not note.allow_approval):
return False
return True
elif new_state == PackageState.CHANGES_NEEDED:
return package.check_perm(user, Permission.APPROVE_NEW)
elif new_state == PackageState.WIP:
return package.check_perm(user, Permission.EDIT_PACKAGE) and \
(user in package.maintainers or user.rank.at_least(UserRank.ADMIN))
return True